From 8ab5a49648a2e8848a4e6f4c947e1e45218b32af Mon Sep 17 00:00:00 2001 From: Lennart van der Molen Date: Sat, 15 Nov 2025 10:51:41 +0100 Subject: [PATCH] Add activity balance bar to radial visuals --- web/style.css | 110 +++++++++++ web/ui/visuals/ActivityShareBar.js | 173 ++++++++++++++++++ web/ui/visuals/RadialUrchin.js | 47 +++++ .../__tests__/activityShareBar.test.js | 28 +++ web/ui/visuals/package.json | 3 + 5 files changed, 361 insertions(+) create mode 100644 web/ui/visuals/ActivityShareBar.js create mode 100644 web/ui/visuals/__tests__/activityShareBar.test.js create mode 100644 web/ui/visuals/package.json diff --git a/web/style.css b/web/style.css index 4373f85..8dd74e0 100644 --- a/web/style.css +++ b/web/style.css @@ -1171,6 +1171,116 @@ body { align-items: flex-start; } +.radial-urchin__share { + position: relative; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 18px; + background: rgba(15, 23, 42, 0.65); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + box-shadow: 0 18px 32px rgba(15, 23, 42, 0.32); +} + +.activity-share__header { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + color: #cbd5f5; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.activity-share__title { + color: #e2e8f0; +} + +.activity-share__total { + font-size: 11px; + font-weight: 500; + color: rgba(148, 163, 184, 0.85); +} + +.activity-share__track { + display: flex; + border-radius: 14px; + overflow: hidden; + min-height: 32px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgba(148, 163, 184, 0.12); + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.25); +} + +.activity-share__segment { + flex: 1 1 0%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + padding: 4px 8px; + min-width: 6px; + background: var(--segment-color, #6366f1); + color: var(--segment-text-color, #0f172a); + transition: transform 0.18s ease; + cursor: pointer; +} + +.activity-share__segment:hover { + transform: translateY(-1px); +} + +.activity-share__segment-label { + font-size: 12px; + font-weight: 600; + text-shadow: 0 1px 2px rgba(15, 23, 42, 0.35); + white-space: nowrap; +} + +.activity-share__empty { + margin: 0; + font-size: 12px; + color: rgba(148, 163, 184, 0.85); +} + +.activity-share__tooltip { + position: absolute; + transform: translate(-50%, -100%) translateY(-12px); + background: rgba(15, 23, 42, 0.92); + border-radius: 12px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.4); + box-shadow: 0 16px 28px rgba(15, 23, 42, 0.45); + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + pointer-events: none; + color: #f8fafc; + z-index: 2; +} + +.activity-share__tooltip strong { + font-size: 13px; + font-weight: 700; + color: #e0e7ff; +} + +.activity-share__tooltip span { + display: block; + color: rgba(226, 232, 240, 0.92); +} + +.activity-share__track[hidden] { + display: none; +} + +.activity-share__tooltip[hidden] { + display: none; +} + .radial-urchin__legend-empty { margin: 0; color: #94a3b8; diff --git a/web/ui/visuals/ActivityShareBar.js b/web/ui/visuals/ActivityShareBar.js new file mode 100644 index 0000000..9e643c1 --- /dev/null +++ b/web/ui/visuals/ActivityShareBar.js @@ -0,0 +1,173 @@ +import { formatDuration } from './useUrchinLayout.js'; + +function toHexChannel(value) { + const channel = Number.parseInt(value, 16); + if (Number.isNaN(channel)) { + return 0; + } + return channel; +} + +function computeTextColor(background) { + if (typeof background !== 'string') { + return '#0f172a'; + } + let hex = background.trim(); + if (hex.startsWith('#')) { + hex = hex.slice(1); + } + if (hex.length === 3) { + hex = hex + .split('') + .map((char) => char + char) + .join(''); + } + if (hex.length !== 6) { + return '#0f172a'; + } + const r = toHexChannel(hex.slice(0, 2)); + const g = toHexChannel(hex.slice(2, 4)); + const b = toHexChannel(hex.slice(4, 6)); + const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + return luminance > 0.6 ? '#0f172a' : '#f8fafc'; +} + +export function prepareActivityShareSegments(activities) { + const filtered = Array.isArray(activities) + ? activities.filter((activity) => Number.isFinite(activity?.minutes) && activity.minutes > 0) + : []; + const totalMinutes = filtered.reduce((sum, activity) => sum + activity.minutes, 0); + if (totalMinutes <= 0) { + return { totalMinutes: 0, segments: [] }; + } + const segments = filtered.map((activity) => ({ + id: activity.id ?? activity.label, + label: activity.label, + minutes: activity.minutes, + color: activity.color, + percentage: activity.minutes / totalMinutes, + })); + return { totalMinutes, segments }; +} + +export class ActivityShareBar { + constructor(container) { + this.root = container; + this.root.classList.add('radial-urchin__share'); + this.root.setAttribute('role', 'group'); + this.root.setAttribute('aria-label', 'Activity balance overview'); + + this.header = document.createElement('div'); + this.header.className = 'activity-share__header'; + this.title = document.createElement('span'); + this.title.className = 'activity-share__title'; + this.title.textContent = 'Activity balance'; + this.total = document.createElement('span'); + this.total.className = 'activity-share__total'; + this.total.textContent = '—'; + this.header.append(this.title, this.total); + + this.track = document.createElement('div'); + this.track.className = 'activity-share__track'; + this.track.setAttribute('role', 'list'); + this.track.hidden = true; + this.track.addEventListener('mouseleave', () => { + this.hideTooltip(); + }); + + this.empty = document.createElement('p'); + this.empty.className = 'activity-share__empty'; + this.empty.textContent = 'Select activities to see their balance.'; + this.empty.hidden = false; + + this.tooltip = document.createElement('div'); + this.tooltip.className = 'activity-share__tooltip'; + this.tooltip.hidden = true; + this.tooltip.setAttribute('role', 'dialog'); + this.tooltip.setAttribute('aria-live', 'polite'); + this.tooltip.setAttribute('aria-hidden', 'true'); + + this.root.append(this.header, this.track, this.empty, this.tooltip); + + this.currentSegments = []; + this.boundHideTooltip = this.hideTooltip.bind(this); + } + + update(segments, totalMinutes = 0) { + this.hideTooltip(); + const hasSegments = Array.isArray(segments) && segments.length > 0 && totalMinutes > 0; + this.track.innerHTML = ''; + this.currentSegments = hasSegments ? segments : []; + this.total.textContent = hasSegments ? formatDuration(Math.round(totalMinutes)) : '—'; + this.track.hidden = !hasSegments; + this.empty.hidden = hasSegments; + if (!hasSegments) { + return; + } + + segments.forEach((segment, index) => { + const element = document.createElement('div'); + element.className = 'activity-share__segment'; + element.style.setProperty('--segment-color', segment.color || '#6366f1'); + element.style.setProperty('--segment-text-color', computeTextColor(segment.color)); + element.style.flexGrow = String(segment.minutes); + element.setAttribute('role', 'listitem'); + const percentValue = Math.round(segment.percentage * 100); + const labelText = this.getSegmentLabel(segment, percentValue); + if (labelText) { + const label = document.createElement('span'); + label.className = 'activity-share__segment-label'; + label.textContent = labelText; + element.append(label); + } + element.setAttribute( + 'aria-label', + `${segment.label}: ${formatDuration(Math.round(segment.minutes))} (${percentValue}%)`, + ); + element.dataset.index = String(index); + element.addEventListener('mouseenter', (event) => { + this.showTooltip(event, segment); + }); + element.addEventListener('mousemove', (event) => { + this.positionTooltip(event); + }); + element.addEventListener('mouseleave', this.boundHideTooltip); + this.track.append(element); + }); + } + + getSegmentLabel(segment, percentValue) { + if (segment.percentage >= 0.18) { + return `${segment.label} · ${percentValue}%`; + } + if (segment.percentage >= 0.1) { + return `${percentValue}%`; + } + return ''; + } + + showTooltip(event, segment) { + const percentValue = Math.round(segment.percentage * 100); + const durationLabel = formatDuration(Math.round(segment.minutes)); + this.tooltip.innerHTML = `${segment.label}${durationLabel}${percentValue}%`; + this.tooltip.hidden = false; + this.tooltip.setAttribute('aria-hidden', 'false'); + this.positionTooltip(event); + } + + positionTooltip(event) { + if (this.tooltip.hidden) { + return; + } + const rootRect = this.root.getBoundingClientRect(); + const x = event.clientX - rootRect.left; + const y = event.clientY - rootRect.top; + this.tooltip.style.left = `${x}px`; + this.tooltip.style.top = `${y}px`; + } + + hideTooltip() { + this.tooltip.hidden = true; + this.tooltip.setAttribute('aria-hidden', 'true'); + } +} diff --git a/web/ui/visuals/RadialUrchin.js b/web/ui/visuals/RadialUrchin.js index 07c38b9..60ea941 100644 --- a/web/ui/visuals/RadialUrchin.js +++ b/web/ui/visuals/RadialUrchin.js @@ -4,6 +4,7 @@ import { formatDuration, minutesToTime, } from './useUrchinLayout.js'; +import { ActivityShareBar, prepareActivityShareSegments } from './ActivityShareBar.js'; import { mapLabelToColor, resolveSurface, resolveStateLayer } from './palette.js'; const FULL_DAY_MINUTES = 24 * 60; @@ -142,6 +143,8 @@ export class RadialUrchin { this.metaElement = null; this.metaSlot = null; + this.shareBar = null; + this.shareContainer = null; this.handleResize = this.handleResize.bind(this); this.handlePointerMove = this.handlePointerMove.bind(this); @@ -307,6 +310,10 @@ export class RadialUrchin { this.exportPngButton, ); + this.shareContainer = document.createElement('div'); + this.container.append(this.shareContainer); + this.shareBar = new ActivityShareBar(this.shareContainer); + this.canvasWrapper = document.createElement('div'); this.canvasWrapper.className = 'radial-urchin__stage visuals-canvas-block'; this.container.append(this.canvasWrapper); @@ -550,6 +557,7 @@ export class RadialUrchin { if (!this.layout) { this.displayArcs = []; this.visibleArcs = []; + this.updateShareBar(); return; } const zoom = this.getZoom(); @@ -589,6 +597,45 @@ export class RadialUrchin { return a.ringIndex - b.ringIndex; }); this.displayMaxRadius = (this.layout?.maxRadius || 160) * scale; + this.updateShareBar(); + } + + computeVisibleActivityTotals() { + if (!Array.isArray(this.visibleArcs) || this.visibleArcs.length === 0) { + return []; + } + const totals = new Map(); + this.visibleArcs.forEach((arc) => { + const duration = Number.isFinite(arc.segmentDuration) + ? arc.segmentDuration + : Number.isFinite(arc.duration) + ? arc.duration + : 0; + if (!(duration > 0)) { + return; + } + const label = arc.label || arc.event?.activity || 'Activity'; + if (!totals.has(label)) { + totals.set(label, { + id: label, + label, + minutes: 0, + color: arc.color || mapLabelToColor(label, { highContrast: this.state.highContrast }), + }); + } + const entry = totals.get(label); + entry.minutes += duration; + }); + return Array.from(totals.values()).sort((a, b) => b.minutes - a.minutes); + } + + updateShareBar() { + if (!this.shareBar) { + return; + } + const totals = this.computeVisibleActivityTotals(); + const { segments, totalMinutes } = prepareActivityShareSegments(totals); + this.shareBar.update(segments, totalMinutes); } computeSegments(arc, zoom) { diff --git a/web/ui/visuals/__tests__/activityShareBar.test.js b/web/ui/visuals/__tests__/activityShareBar.test.js new file mode 100644 index 0000000..874b2b7 --- /dev/null +++ b/web/ui/visuals/__tests__/activityShareBar.test.js @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { prepareActivityShareSegments } from '../ActivityShareBar.js'; + +test('prepareActivityShareSegments ignores empty values and normalises percentages', () => { + const { segments, totalMinutes } = prepareActivityShareSegments([ + { id: 'work', label: 'Work', minutes: 300, color: '#123456' }, + { id: 'sleep', label: 'Sleep', minutes: 420, color: '#654321' }, + { id: 'zero', label: 'Zero', minutes: 0, color: '#000000' }, + ]); + + assert.equal(segments.length, 2); + assert.equal(totalMinutes, 720); + const work = segments.find((segment) => segment.id === 'work'); + const sleep = segments.find((segment) => segment.id === 'sleep'); + assert(work); + assert(sleep); + assert.ok(Math.abs(work.percentage - 300 / 720) < 1e-9); + assert.ok(Math.abs(sleep.percentage - 420 / 720) < 1e-9); +}); + +test('prepareActivityShareSegments returns empty state when no minutes available', () => { + const { segments, totalMinutes } = prepareActivityShareSegments([ + { id: 'breakfast', label: 'Breakfast', minutes: 0, color: '#fff' }, + ]); + assert.equal(totalMinutes, 0); + assert.deepEqual(segments, []); +}); diff --git a/web/ui/visuals/package.json b/web/ui/visuals/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/web/ui/visuals/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +}