From d23bd825d2735756d7301bbe6363cf38414f5e84 Mon Sep 17 00:00:00 2001 From: Lennart van der Molen Date: Wed, 12 Nov 2025 22:42:14 +0100 Subject: [PATCH] ui(visuals): Material 3 radial urchin (interactive), legend/controls, exports; replace PNG calendar --- web/app.js | 150 ++++- web/engineRegistry.js | 4 +- web/index.html | 4 +- web/style.css | 337 +++++++++- web/ui/visuals/RadialUrchin.js | 1010 +++++++++++++++++++++++++++++ web/ui/visuals/RadialUrchin.tsx | 20 + web/ui/visuals/palette.js | 65 ++ web/ui/visuals/useUrchinLayout.js | 237 +++++++ web/ui/visuals/useUrchinLayout.ts | 50 ++ 9 files changed, 1867 insertions(+), 10 deletions(-) create mode 100644 web/ui/visuals/RadialUrchin.js create mode 100644 web/ui/visuals/RadialUrchin.tsx create mode 100644 web/ui/visuals/palette.js create mode 100644 web/ui/visuals/useUrchinLayout.js create mode 100644 web/ui/visuals/useUrchinLayout.ts diff --git a/web/app.js b/web/app.js index 20d18fd..367f6f6 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,5 @@ import { DEBUG } from './debug.js'; +import { createRadialUrchin } from './ui/visuals/RadialUrchin.js'; const tabButtons = Array.from( document.querySelectorAll('.tab[data-tab-scope="root"]') @@ -29,6 +30,152 @@ let jsonSummaryElement; let currentJsonText = ''; let currentJsonMetadata = { variant: '', rig: '', week: '', events: null }; +const VISUALS_LEGACY_FLAG = 'wyrd.visuals.legacy'; +const visualsState = { + container: null, + mount: null, + fallback: null, + fallbackImg: null, + fallbackMessage: null, + urchin: null, + useLegacy: false, +}; +let lastVisualPayload = null; + +function readVisualsLegacyFlag() { + try { + return localStorage.getItem(VISUALS_LEGACY_FLAG) === '1'; + } catch (error) { + return false; + } +} + +function persistVisualsLegacyFlag(enabled) { + try { + if (enabled) { + localStorage.setItem(VISUALS_LEGACY_FLAG, '1'); + } else { + localStorage.removeItem(VISUALS_LEGACY_FLAG); + } + } catch (error) { + // ignore storage failures + } +} + +function syncVisualsVisibility() { + if (visualsState.mount) { + visualsState.mount.hidden = visualsState.useLegacy; + } + if (visualsState.fallback) { + visualsState.fallback.hidden = !visualsState.useLegacy; + } +} + +function resolveLegacyPng(payload) { + if (!payload || typeof payload !== 'object') { + return ''; + } + const metadata = payload.metadata || {}; + const candidates = [ + metadata.preview_png, + metadata.preview_png_url, + metadata.calendar_png, + metadata.calendar_png_url, + payload.preview_png, + payload.preview_png_url, + ]; + return candidates.find((value) => typeof value === 'string' && value.trim().length > 0) || ''; +} + +function updateVisuals(payload) { + lastVisualPayload = payload && typeof payload === 'object' ? payload : null; + if (visualsState.useLegacy) { + const src = resolveLegacyPng(lastVisualPayload); + if (visualsState.fallbackImg) { + if (src) { + visualsState.fallbackImg.src = src; + visualsState.fallbackImg.alt = 'Schedule preview (legacy PNG)'; + visualsState.fallbackImg.hidden = false; + if (visualsState.fallbackMessage) { + visualsState.fallbackMessage.hidden = true; + } + } else { + visualsState.fallbackImg.removeAttribute('src'); + visualsState.fallbackImg.alt = 'Legacy calendar preview unavailable'; + visualsState.fallbackImg.hidden = true; + if (visualsState.fallbackMessage) { + visualsState.fallbackMessage.hidden = false; + visualsState.fallbackMessage.textContent = 'Legacy PNG preview unavailable. Run generator to produce a fresh export.'; + } + } + } + } + if (visualsState.urchin) { + visualsState.urchin.update({ data: visualsState.useLegacy ? null : lastVisualPayload }); + } +} + +const visualsContainerElement = + document.querySelector('#visuals-container') || document.querySelector('#calendar-container'); + +if (visualsContainerElement) { + visualsState.container = visualsContainerElement; + visualsState.useLegacy = readVisualsLegacyFlag(); + + const mount = document.createElement('div'); + mount.className = 'visuals-mount'; + visualsContainerElement.append(mount); + visualsState.mount = mount; + + const fallback = document.createElement('div'); + fallback.className = 'visuals-fallback-panel'; + const fallbackImg = document.createElement('img'); + fallbackImg.className = 'visuals-fallback-panel__image'; + fallbackImg.alt = 'Legacy calendar preview unavailable'; + fallbackImg.hidden = true; + const fallbackMessage = document.createElement('p'); + fallbackMessage.className = 'visuals-fallback-panel__message'; + fallbackMessage.textContent = 'Legacy PNG preview unavailable.'; + fallbackMessage.hidden = true; + fallback.append(fallbackImg, fallbackMessage); + visualsContainerElement.append(fallback); + visualsState.fallback = fallback; + visualsState.fallbackImg = fallbackImg; + visualsState.fallbackMessage = fallbackMessage; + + visualsState.urchin = createRadialUrchin(mount, { + data: null, + mode: 'day-rings', + onSelect(activity) { + if (!activity) { + return; + } + dispatchIntent({ + type: INTENT_TYPES.SHOW_TOAST, + payload: { + message: activity.label || 'Activity selected', + description: `${activity.start || '?'} – ${activity.end || '?'}`, + intent: 'info', + duration: 1800, + }, + }); + }, + }); + + syncVisualsVisibility(); + updateVisuals(null); + + if (typeof window !== 'undefined') { + window.WYRD_SET_VISUALS_LEGACY = (enabled) => { + const flag = Boolean(enabled); + visualsState.useLegacy = flag; + persistVisualsLegacyFlag(flag); + syncVisualsVisibility(); + updateVisuals(lastVisualPayload); + }; + } +} + let getConfigSnapshot = () => ({ classId: 'calendar', variant: '', @@ -370,6 +517,7 @@ function setJsonPayload(payload, options = {}) { currentJsonMetadata = metadata; updateJsonActionsState(); updateJsonSummaryDisplay(); + updateVisuals(parsedPayload); } loadRunHistoryFromStorage(); @@ -940,7 +1088,7 @@ const paletteList = document.createElement('ul'); paletteList.className = 'command-palette-actions'; const paletteActions = [ - { label: 'Go to Calendar', tab: 'calendar' }, + { label: 'Go to Visuals', tab: 'calendar' }, { label: 'Go to Config', tab: 'config' }, { label: 'Go to Console', tab: 'console' }, { label: 'Go to JSON', tab: 'json' }, diff --git a/web/engineRegistry.js b/web/engineRegistry.js index 6cb4070..17798dd 100644 --- a/web/engineRegistry.js +++ b/web/engineRegistry.js @@ -3,14 +3,14 @@ import { calendarFoundation } from "./classes/calendar/foundation.js"; export const engineRegistry = { classes: { calendar: { - title: "Calendar", + title: "Visuals", foundation: calendarFoundation, variants: { mk1: { title: "MK1", rigs: { default: { title: "Default" } } }, mk2: { title: "MK2", rigs: { - calendar: { title: "Calendar" }, + calendar: { title: "Visuals" }, workforce: { title: "Workforce" } } } diff --git a/web/index.html b/web/index.html index 1d55615..c6cca25 100644 --- a/web/index.html +++ b/web/index.html @@ -9,7 +9,7 @@
-
+
diff --git a/web/style.css b/web/style.css index c63fb36..57c5353 100644 --- a/web/style.css +++ b/web/style.css @@ -420,11 +420,12 @@ body { outline-offset: 2px; } -#calendar-container { - min-height: 380px; - border: 1px dashed #3a3f4f; - border-radius: 6px; - background: rgba(58, 63, 79, 0.1); +#visuals-container { + min-height: 420px; + border-radius: 20px; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.75), rgba(15, 23, 42, 0.3)); + padding: 20px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); } .console-pane { @@ -521,6 +522,332 @@ body { display: none; } +.visuals-mount { + position: relative; + min-height: 420px; +} + +.visuals-fallback-panel { + display: flex; + align-items: center; + justify-content: center; + min-height: 320px; + border: 1px dashed rgba(148, 163, 184, 0.3); + border-radius: 16px; + padding: 24px; + background: rgba(15, 23, 42, 0.4); +} + +.visuals-fallback-panel__image { + max-width: 100%; + max-height: 380px; + border-radius: 12px; + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.45); +} + +.visuals-fallback-panel__message { + margin: 0; + margin-top: 12px; + color: #cbd5f5; + font-size: 14px; + text-align: center; +} + +.radial-urchin-root { + display: flex; + flex-direction: column; + gap: 16px; + color: #f8fafc; + font-family: 'Inter', 'SF Pro Text', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.radial-urchin { + display: flex; + flex-direction: column; + gap: 16px; +} + +.radial-urchin__controls { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; + justify-content: space-between; + background: rgba(15, 23, 42, 0.65); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + padding: 16px 18px; + box-shadow: 0 22px 36px rgba(15, 23, 42, 0.38); +} + +.radial-urchin__segmented { + display: inline-flex; + align-items: center; + background: rgba(100, 116, 139, 0.22); + border-radius: 999px; + padding: 4px; + gap: 4px; +} + +.radial-urchin__segment { + border: none; + background: transparent; + color: #e2e8f0; + font-size: 13px; + font-weight: 600; + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; +} + +.radial-urchin__segment.is-active { + background: rgba(129, 140, 248, 0.24); + color: #f8fafc; + box-shadow: inset 0 0 0 1px rgba(99, 102, 241, 0.6); +} + +.radial-urchin__segment:focus-visible { + outline: 2px solid rgba(99, 102, 241, 0.8); + outline-offset: 2px; +} + +.radial-urchin__legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + flex: 1 1 auto; +} + +.radial-urchin__legend-empty { + margin: 0; + color: #94a3b8; + font-size: 13px; +} + +.radial-urchin__chip { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(148, 163, 184, 0.16); + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 999px; + padding: 6px 12px; + font-size: 12px; + color: #e2e8f0; + cursor: pointer; + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.25); + transition: background 0.18s ease, border 0.18s ease; +} + +.radial-urchin__chip input[type='checkbox'] { + accent-color: var(--chip-color, #6366f1); +} + +.radial-urchin__chip:hover { + background: rgba(99, 102, 241, 0.18); + border-color: rgba(99, 102, 241, 0.32); +} + +.radial-urchin__actions { + display: flex; + align-items: center; + gap: 12px; +} + +.radial-urchin__icon-button { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: rgba(129, 140, 248, 0.24); + color: #ede9fe; + font-size: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.16s ease, box-shadow 0.16s ease; + box-shadow: 0 12px 24px rgba(99, 102, 241, 0.35); +} + +.radial-urchin__icon-button:hover { + transform: translateY(-1px); +} + +.radial-urchin__icon-button.is-active { + background: rgba(79, 70, 229, 0.48); +} + +.radial-urchin__speed { + display: inline-flex; + gap: 4px; + background: rgba(51, 65, 85, 0.65); + border-radius: 999px; + padding: 4px; +} + +.radial-urchin__speed-option { + border: none; + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + color: #cbd5f5; + background: transparent; + cursor: pointer; + transition: background 0.18s ease, color 0.18s ease; +} + +.radial-urchin__speed-option.is-active { + background: rgba(79, 70, 229, 0.55); + color: #fff; + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.32); +} + +.radial-urchin__scrub { + appearance: none; + width: 180px; + height: 4px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(99, 102, 241, 0.65), rgba(34, 211, 238, 0.65)); +} + +.radial-urchin__scrub::-webkit-slider-thumb { + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: #38bdf8; + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.25); + cursor: pointer; +} + +.radial-urchin__scrub::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: #38bdf8; + border: none; + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.25); + cursor: pointer; +} + +.radial-urchin__reset, +.radial-urchin__export { + border: none; + border-radius: 999px; + padding: 8px 14px; + font-size: 12px; + font-weight: 600; + background: rgba(30, 41, 59, 0.9); + color: #e2e8f0; + cursor: pointer; + transition: background 0.18s ease, box-shadow 0.18s ease; +} + +.radial-urchin__reset:hover, +.radial-urchin__export:hover { + background: rgba(59, 130, 246, 0.65); + box-shadow: 0 12px 24px rgba(37, 99, 235, 0.35); +} + +.radial-urchin__stage { + position: relative; + min-height: 360px; + border-radius: 20px; + background: radial-gradient(circle at top, rgba(148, 163, 184, 0.15), rgba(15, 23, 42, 0.75)); + border: 1px solid rgba(148, 163, 184, 0.2); + overflow: visible; +} + +.radial-urchin__canvas { + width: 100%; + height: 100%; + display: block; +} + +.radial-urchin__overlay { + position: absolute; + inset: 0; + pointer-events: none; +} + +.radial-urchin__selection { + fill: rgba(56, 189, 248, 0.18); + stroke: rgba(56, 189, 248, 0.85); + stroke-width: 1.5; + filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.55)); +} + +.radial-urchin__hover { + fill: rgba(99, 102, 241, 0.14); + stroke: rgba(129, 140, 248, 0.8); + stroke-width: 1; +} + +.radial-urchin__scrub-line { + stroke: rgba(14, 165, 233, 0.9); + stroke-width: 1.5; + stroke-dasharray: 6 4; +} + +.radial-urchin__tooltip { + position: absolute; + max-width: 220px; + background: rgba(15, 23, 42, 0.92); + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 16px; + padding: 14px; + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.55); + backdrop-filter: blur(12px); + pointer-events: none; +} + +.radial-urchin__tooltip p { + margin: 0; + font-size: 13px; + color: #e2e8f0; +} + +.radial-urchin__tooltip p strong { + font-size: 14px; + font-weight: 700; + color: #f8fafc; +} + +.radial-urchin__tooltip p + p { + margin-top: 4px; +} + +.radial-urchin__tooltip p .meta { + color: #94a3b8; + font-size: 12px; +} + +.radial-urchin__tooltip-actions { + display: flex; + justify-content: flex-end; + margin-top: 10px; + pointer-events: auto; +} + +.radial-urchin__tooltip-actions button { + border: none; + border-radius: 999px; + background: rgba(129, 140, 248, 0.35); + color: #f8fafc; + font-size: 12px; + padding: 6px 12px; +} + +.radial-urchin__live { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} + .console-structured-details > summary { cursor: pointer; padding: 8px 12px; diff --git a/web/ui/visuals/RadialUrchin.js b/web/ui/visuals/RadialUrchin.js new file mode 100644 index 0000000..17070a0 --- /dev/null +++ b/web/ui/visuals/RadialUrchin.js @@ -0,0 +1,1010 @@ +import { + computeUrchinLayout, + findNearestArc, + formatDuration, + minutesToTime, +} from './useUrchinLayout.js'; +import { mapLabelToColor, resolveSurface, resolveStateLayer } from './palette.js'; + +const FULL_DAY_MINUTES = 24 * 60; +const TAU = Math.PI * 2; + +const MODE_LABELS = { + 'day-rings': 'Ring by Day', + 'agent-rings': 'Ring by Agent', +}; + +const SPEED_OPTIONS = [ + { label: '0.5×', value: 0.5 }, + { label: '1×', value: 1 }, + { label: '2×', value: 2 }, +]; + +const HOVER_DELAY = 180; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function moduloMinutes(minutes) { + const wrapped = minutes % FULL_DAY_MINUTES; + return wrapped < 0 ? wrapped + FULL_DAY_MINUTES : wrapped; +} + +function describeSegmentPath(cx, cy, innerR, outerR, startAngle, endAngle) { + const largeArc = endAngle - startAngle > Math.PI ? 1 : 0; + const startOuterX = cx + outerR * Math.cos(startAngle); + const startOuterY = cy + outerR * Math.sin(startAngle); + const endOuterX = cx + outerR * Math.cos(endAngle); + const endOuterY = cy + outerR * Math.sin(endAngle); + const startInnerX = cx + innerR * Math.cos(endAngle); + const startInnerY = cy + innerR * Math.sin(endAngle); + const endInnerX = cx + innerR * Math.cos(startAngle); + const endInnerY = cy + innerR * Math.sin(startAngle); + + return [ + 'M', + startOuterX, + startOuterY, + 'A', + outerR, + outerR, + 0, + largeArc, + 1, + endOuterX, + endOuterY, + 'L', + startInnerX, + startInnerY, + 'A', + innerR, + innerR, + 0, + largeArc, + 0, + endInnerX, + endInnerY, + 'Z', + ].join(' '); +} + +function ensureElement(parent, selector, factory) { + let element = parent.querySelector(selector); + if (!element) { + element = factory(); + parent.append(element); + } + return element; +} + +function createDemoData() { + const labels = ['Work', 'Break', 'Sleep', 'Exercise', 'Family']; + const events = []; + for (let day = 0; day < 7; day += 1) { + let cursor = 6 * 60; // start day at 6am + const date = new Date(Date.now() + day * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + events.push({ date, start: '00:00', end: '06:00', label: 'Sleep', activity: 'rest' }); + while (cursor < 22 * 60) { + const label = labels[(day + cursor) % labels.length]; + const duration = [60, 90, 120][(cursor / 30) % 3]; + const end = cursor + duration; + const startTime = minutesToTime(cursor); + const endTime = minutesToTime(end % FULL_DAY_MINUTES); + events.push({ + date, + start: startTime, + end: endTime, + label, + activity: label.toLowerCase(), + agent: day % 2 === 0 ? 'Agent A' : 'Agent B', + }); + cursor = end; + } + events.push({ date, start: '22:00', end: '24:00', label: 'Sleep', activity: 'rest' }); + } + return { + schema_version: 'web_v1_calendar', + week_start: new Date().toISOString().slice(0, 10), + events, + metadata: { demo: true }, + }; +} + +function buildTooltipContent(arc) { + if (!arc) { + return ''; + } + const startMinutes = moduloMinutes(arc.segmentStart ?? arc.startMinutes); + const endMinutes = moduloMinutes(startMinutes + (arc.segmentDuration ?? arc.duration)); + const startTime = minutesToTime(startMinutes); + const endTime = minutesToTime(endMinutes); + const duration = formatDuration(Math.round((arc.segmentDuration ?? arc.duration))); + const lines = [`${arc.label}`, `${startTime} – ${endTime}`, `${duration}`]; + if (arc.event?.activity && arc.event.activity !== arc.label) { + lines.push(`Activity · ${arc.event.activity}`); + } + if (arc.event?.agent) { + lines.push(`Agent · ${arc.event.agent}`); + } else if (arc.event?.metadata?.agent) { + lines.push(`Agent · ${arc.event.metadata.agent}`); + } + if (arc.event?.metadata?.note) { + lines.push(`${arc.event.metadata.note}`); + } + return lines.map((line) => `

${line}

`).join(''); +} + +function getElementRect(element) { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; +} + +export class RadialUrchin { + constructor(root, props = {}) { + this.root = root; + this.props = { data: props.data ?? null, mode: props.mode ?? 'day-rings', selectedAgent: props.selectedAgent, onSelect: props.onSelect ?? (() => {}) }; + this.state = { + hoverArc: null, + selectedArc: null, + focusArc: null, + scrubMinutes: 8 * 60, + playing: false, + playSpeed: 1, + zoomStart: 0, + zoomSpan: FULL_DAY_MINUTES, + highContrast: false, + }; + this.hiddenLabels = new Set(); + this.visibleArcs = []; + this.layout = null; + this.displayArcs = []; + this.hoverTimer = null; + this.lastPointer = null; + this.frameHandle = null; + this.lastTick = null; + this.contrastQuery = null; + + this.handleResize = this.handleResize.bind(this); + this.handlePointerMove = this.handlePointerMove.bind(this); + this.handlePointerLeave = this.handlePointerLeave.bind(this); + this.handleClick = this.handleClick.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleWheel = this.handleWheel.bind(this); + this.handleContrastChange = this.handleContrastChange.bind(this); + + this.setupDom(); + this.update(this.props); + } + + setupDom() { + this.root.classList.add('radial-urchin-root'); + this.root.setAttribute('tabindex', '0'); + this.root.addEventListener('keydown', this.handleKeyDown); + + this.container = document.createElement('div'); + this.container.className = 'radial-urchin'; + this.root.append(this.container); + + this.controlBar = document.createElement('div'); + this.controlBar.className = 'radial-urchin__controls'; + this.container.append(this.controlBar); + + this.modeControl = document.createElement('div'); + this.modeControl.className = 'radial-urchin__segmented'; + this.controlBar.append(this.modeControl); + + this.modeButtons = new Map(); + Object.entries(MODE_LABELS).forEach(([mode, label]) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'radial-urchin__segment'; + button.textContent = label; + button.dataset.mode = mode; + button.addEventListener('click', () => { + this.setMode(mode); + }); + this.modeControl.append(button); + this.modeButtons.set(mode, button); + }); + + this.legendContainer = document.createElement('div'); + this.legendContainer.className = 'radial-urchin__legend'; + this.controlBar.append(this.legendContainer); + + this.actionsContainer = document.createElement('div'); + this.actionsContainer.className = 'radial-urchin__actions'; + this.controlBar.append(this.actionsContainer); + + this.playButton = document.createElement('button'); + this.playButton.type = 'button'; + this.playButton.className = 'radial-urchin__icon-button'; + this.playButton.innerHTML = ''; + this.playButton.setAttribute('aria-label', 'Play schedule replay'); + this.playButton.addEventListener('click', () => { + this.togglePlayback(); + }); + + this.speedGroup = document.createElement('div'); + this.speedGroup.className = 'radial-urchin__speed'; + SPEED_OPTIONS.forEach((option) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'radial-urchin__speed-option'; + button.textContent = option.label; + button.dataset.speed = String(option.value); + button.addEventListener('click', () => { + this.setSpeed(option.value); + }); + this.speedGroup.append(button); + }); + + this.scrubSlider = document.createElement('input'); + this.scrubSlider.type = 'range'; + this.scrubSlider.min = '0'; + this.scrubSlider.max = String(FULL_DAY_MINUTES); + this.scrubSlider.value = String(this.state.scrubMinutes); + this.scrubSlider.className = 'radial-urchin__scrub'; + this.scrubSlider.setAttribute('aria-label', 'Scrub through the day'); + this.scrubSlider.addEventListener('input', () => { + const minutes = Number.parseInt(this.scrubSlider.value, 10); + if (Number.isFinite(minutes)) { + this.setScrub(minutes, { fromPlayback: false }); + } + }); + + this.zoomResetButton = document.createElement('button'); + this.zoomResetButton.type = 'button'; + this.zoomResetButton.textContent = 'Reset zoom'; + this.zoomResetButton.className = 'radial-urchin__reset'; + this.zoomResetButton.addEventListener('click', () => { + this.resetZoom(); + }); + + this.exportSvgButton = document.createElement('button'); + this.exportSvgButton.type = 'button'; + this.exportSvgButton.textContent = 'Export SVG'; + this.exportSvgButton.className = 'radial-urchin__export'; + this.exportSvgButton.addEventListener('click', () => { + const payload = this.exportSVG(); + if (!payload) { + return; + } + const blob = new Blob([payload], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'schedule_visual.svg'; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1500); + }); + + this.exportPngButton = document.createElement('button'); + this.exportPngButton.type = 'button'; + this.exportPngButton.textContent = 'Export PNG'; + this.exportPngButton.className = 'radial-urchin__export'; + this.exportPngButton.addEventListener('click', async () => { + const blob = await this.exportPNG(); + if (!blob) { + return; + } + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'schedule_visual.png'; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1500); + }); + + this.actionsContainer.append( + this.playButton, + this.speedGroup, + this.scrubSlider, + this.zoomResetButton, + this.exportSvgButton, + this.exportPngButton, + ); + + this.canvasWrapper = document.createElement('div'); + this.canvasWrapper.className = 'radial-urchin__stage'; + this.container.append(this.canvasWrapper); + + this.canvas = document.createElement('canvas'); + this.canvas.className = 'radial-urchin__canvas'; + this.canvasWrapper.append(this.canvas); + + this.overlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.overlay.setAttribute('class', 'radial-urchin__overlay'); + this.canvasWrapper.append(this.overlay); + + this.selectionPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + this.selectionPath.setAttribute('class', 'radial-urchin__selection'); + this.overlay.append(this.selectionPath); + + this.hoverPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + this.hoverPath.setAttribute('class', 'radial-urchin__hover'); + this.overlay.append(this.hoverPath); + + this.scrubLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + this.scrubLine.setAttribute('class', 'radial-urchin__scrub-line'); + this.overlay.append(this.scrubLine); + + this.tooltip = document.createElement('div'); + this.tooltip.className = 'radial-urchin__tooltip'; + this.tooltip.setAttribute('role', 'dialog'); + this.tooltip.setAttribute('aria-live', 'polite'); + this.tooltip.hidden = true; + this.canvasWrapper.append(this.tooltip); + + this.tooltipMeta = ensureElement(this.tooltip, '.radial-urchin__tooltip-body', () => { + const body = document.createElement('div'); + body.className = 'radial-urchin__tooltip-body'; + return body; + }); + + this.tooltipActions = ensureElement(this.tooltip, '.radial-urchin__tooltip-actions', () => { + const actions = document.createElement('div'); + actions.className = 'radial-urchin__tooltip-actions'; + return actions; + }); + + this.tooltipPinButton = document.createElement('button'); + this.tooltipPinButton.type = 'button'; + this.tooltipPinButton.textContent = 'Select'; + this.tooltipPinButton.addEventListener('click', () => { + if (this.state.hoverArc) { + this.setSelection(this.state.hoverArc); + } + }); + + this.tooltipActions.append(this.tooltipPinButton); + + this.liveRegion = document.createElement('div'); + this.liveRegion.className = 'radial-urchin__live'; + this.liveRegion.setAttribute('aria-live', 'polite'); + this.liveRegion.setAttribute('aria-atomic', 'true'); + this.liveRegion.textContent = ''; + this.container.append(this.liveRegion); + + this.canvas.addEventListener('pointermove', this.handlePointerMove); + this.canvas.addEventListener('pointerleave', this.handlePointerLeave); + this.canvas.addEventListener('click', this.handleClick); + this.canvas.addEventListener('wheel', this.handleWheel, { passive: false }); + + this.resizeObserver = new ResizeObserver(this.handleResize); + this.resizeObserver.observe(this.canvasWrapper); + + this.offscreen = document.createElement('canvas'); + + if (typeof window !== 'undefined' && window.matchMedia) { + try { + this.contrastQuery = window.matchMedia('(prefers-contrast: more)'); + this.state.highContrast = Boolean(this.contrastQuery.matches); + if (typeof this.contrastQuery.addEventListener === 'function') { + this.contrastQuery.addEventListener('change', this.handleContrastChange); + } else if (typeof this.contrastQuery.addListener === 'function') { + this.contrastQuery.addListener(this.handleContrastChange); + } + } catch (error) { + this.contrastQuery = null; + } + } + } + + destroy() { + this.stopPlayback(); + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + if (this.contrastQuery) { + if (typeof this.contrastQuery.removeEventListener === 'function') { + this.contrastQuery.removeEventListener('change', this.handleContrastChange); + } else if (typeof this.contrastQuery.removeListener === 'function') { + this.contrastQuery.removeListener(this.handleContrastChange); + } + } + this.root.removeEventListener('keydown', this.handleKeyDown); + } + + update(props = {}) { + this.props = { ...this.props, ...props }; + if (!this.props.data || !Array.isArray(this.props.data.events) || this.props.data.events.length === 0) { + if (!this.demoData) { + this.demoData = createDemoData(); + } + this.layout = computeUrchinLayout(this.demoData, { mode: this.state.mode ?? this.props.mode }); + } else { + this.layout = computeUrchinLayout(this.props.data, { mode: this.state.mode ?? this.props.mode, includeLabels: (label) => !this.hiddenLabels.has(label), highContrast: this.state.highContrast }); + } + this.updateLegend(); + this.refreshModeButtons(); + this.rebuildDisplayArcs(); + this.render(); + } + + handleContrastChange(event) { + this.state.highContrast = Boolean(event.matches); + this.update({}); + } + + refreshModeButtons() { + this.modeButtons.forEach((button, mode) => { + const active = mode === this.getMode(); + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + Array.from(this.speedGroup.children).forEach((button) => { + const speedValue = Number.parseFloat(button.dataset.speed || '1'); + const active = Math.abs(speedValue - this.state.playSpeed) < 0.01; + button.classList.toggle('is-active', active); + }); + this.playButton.classList.toggle('is-active', this.state.playing); + this.playButton.innerHTML = this.state.playing + ? '❚❚' + : ''; + } + + getMode() { + return this.state.mode || this.props.mode || 'day-rings'; + } + + setMode(mode) { + if (mode === this.getMode()) { + return; + } + this.state.mode = mode; + this.update({ mode }); + } + + updateLegend() { + this.legendContainer.innerHTML = ''; + if (!this.layout?.totals?.length) { + const empty = document.createElement('p'); + empty.className = 'radial-urchin__legend-empty'; + empty.textContent = 'No activities available. Run generator to populate schedule.'; + this.legendContainer.append(empty); + return; + } + const fragment = document.createDocumentFragment(); + this.layout.totals.forEach(({ label, minutes }) => { + const chip = document.createElement('label'); + chip.className = 'radial-urchin__chip'; + chip.style.setProperty('--chip-color', mapLabelToColor(label, { highContrast: this.state.highContrast })); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = !this.hiddenLabels.has(label); + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + this.hiddenLabels.delete(label); + } else { + this.hiddenLabels.add(label); + } + this.update({}); + }); + const text = document.createElement('span'); + text.textContent = `${label} · ${formatDuration(Math.round(minutes))}`; + chip.append(checkbox, text); + fragment.append(chip); + }); + this.legendContainer.append(fragment); + } + + rebuildDisplayArcs() { + if (!this.layout) { + this.displayArcs = []; + this.visibleArcs = []; + return; + } + const zoom = this.getZoom(); + const scale = this.computeRadiusScale(); + const visible = []; + const arcs = []; + this.layout.arcs.forEach((arc) => { + if (this.hiddenLabels.has(arc.label)) { + return; + } + const agentMatch = this.isAgentMatch(arc.event); + const segments = this.computeSegments(arc, zoom); + segments.forEach((segment, index) => { + const startAngle = this.mapMinutesToAngle(segment.startRelative); + const endAngle = this.mapMinutesToAngle(segment.startRelative + segment.duration); + const displayArc = { + ...arc, + id: index === 0 ? arc.id : `${arc.id}:${index}`, + startAngle, + endAngle, + segmentStart: moduloMinutes(segment.absoluteStart), + segmentDuration: segment.duration, + centerAngle: startAngle + (endAngle - startAngle) / 2, + agentMatch, + innerRadius: arc.innerRadius * scale, + outerRadius: arc.outerRadius * scale, + }; + arcs.push(displayArc); + visible.push(displayArc); + }); + }); + this.displayArcs = arcs; + this.visibleArcs = visible.sort((a, b) => { + if (a.ringIndex === b.ringIndex) { + return a.startMinutes - b.startMinutes; + } + return a.ringIndex - b.ringIndex; + }); + this.displayMaxRadius = (this.layout?.maxRadius || 160) * scale; + } + + computeSegments(arc, zoom) { + const windowStart = zoom.start; + const windowEnd = zoom.start + zoom.span; + const baseOffsets = zoom.span >= FULL_DAY_MINUTES ? [0] : [-FULL_DAY_MINUTES, 0, FULL_DAY_MINUTES]; + const segments = []; + baseOffsets.forEach((offset) => { + const start = arc.startMinutes + offset; + const end = start + arc.duration; + const clippedStart = Math.max(start, windowStart); + const clippedEnd = Math.min(end, windowEnd); + if (clippedEnd > clippedStart) { + segments.push({ + absoluteStart: clippedStart, + absoluteEnd: clippedEnd, + startRelative: clippedStart - zoom.start, + duration: clippedEnd - clippedStart, + }); + } + }); + return segments; + } + + mapMinutesToAngle(relativeMinutes) { + const zoom = this.getZoom(); + if (zoom.span >= FULL_DAY_MINUTES) { + const normalized = moduloMinutes(relativeMinutes + zoom.start); + return (normalized / FULL_DAY_MINUTES) * TAU - Math.PI / 2; + } + const clamped = clamp(relativeMinutes, 0, zoom.span); + const angle = (clamped / zoom.span) * TAU - Math.PI / 2; + return angle; + } + + computeRadiusScale() { + if (!this.layout) { + return 1; + } + if (!this.canvasRect) { + this.canvasRect = getElementRect(this.canvas); + } + const width = this.canvasRect?.width || this.canvas.width || 0; + const height = this.canvasRect?.height || this.canvas.height || 0; + const minSide = Math.min(width, height); + if (!minSide) { + return 1; + } + const maxRadius = this.layout.maxRadius || 160; + const padding = 32; + const usable = minSide / 2 - padding; + if (usable <= 0) { + return 1; + } + return usable / maxRadius; + } + + getZoom() { + return { start: this.state.zoomStart ?? 0, span: this.state.zoomSpan ?? FULL_DAY_MINUTES }; + } + + resetZoom() { + this.state.zoomStart = 0; + this.state.zoomSpan = FULL_DAY_MINUTES; + this.rebuildDisplayArcs(); + this.render(); + } + + handleWheel(event) { + if (!this.canvasRect) { + return; + } + event.preventDefault(); + const { left, top, width, height } = this.canvasRect; + const centerX = width / 2; + const centerY = height / 2; + const x = event.clientX - left - centerX; + const y = event.clientY - top - centerY; + const angle = Math.atan2(y, x); + + const current = this.getZoom(); + let span = current.span * (1 + event.deltaY * 0.0015); + span = clamp(span, 120, FULL_DAY_MINUTES); + + const angleRatio = (angle + Math.PI / 2) / TAU; + const focusMinutes = moduloMinutes((angleRatio < 0 ? angleRatio + 1 : angleRatio) * current.span + current.start); + + const newStart = moduloMinutes(focusMinutes - span * 0.5); + + this.state.zoomSpan = span; + this.state.zoomStart = newStart; + this.rebuildDisplayArcs(); + this.render(); + } + + handleResize(entries) { + if (!entries || entries.length === 0) { + return; + } + const entry = entries[0]; + const width = entry.contentRect?.width ?? this.canvasWrapper.clientWidth; + const height = entry.contentRect?.height ?? this.canvasWrapper.clientHeight; + const devicePixelRatio = window.devicePixelRatio || 1; + this.canvas.width = Math.round(width * devicePixelRatio); + this.canvas.height = Math.round(height * devicePixelRatio); + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + this.offscreen.width = this.canvas.width; + this.offscreen.height = this.canvas.height; + this.overlay.setAttribute('viewBox', `0 0 ${width} ${height}`); + this.overlay.setAttribute('width', width); + this.overlay.setAttribute('height', height); + this.center = { x: width / 2, y: height / 2 }; + this.canvasRect = getElementRect(this.canvas); + this.rebuildDisplayArcs(); + this.render(); + } + + render() { + if (!this.canvas || !this.canvas.width) { + return; + } + const ctx = this.offscreen.getContext('2d'); + if (!ctx) { + return; + } + ctx.clearRect(0, 0, this.offscreen.width, this.offscreen.height); + const dpr = window.devicePixelRatio || 1; + ctx.save(); + ctx.scale(dpr, dpr); + ctx.translate(this.center.x, this.center.y); + ctx.lineWidth = 1; + ctx.lineCap = 'butt'; + ctx.lineJoin = 'round'; + + this.displayArcs.forEach((arc) => { + const color = arc.color || mapLabelToColor(arc.label, { highContrast: this.state.highContrast }); + ctx.globalAlpha = arc.agentMatch ? 1 : 0.25; + ctx.beginPath(); + ctx.fillStyle = resolveStateLayer(color, 0.24); + ctx.strokeStyle = color; + ctx.moveTo(0, 0); + ctx.arc(0, 0, arc.outerRadius, arc.startAngle, arc.endAngle, false); + ctx.arc(0, 0, arc.innerRadius, arc.endAngle, arc.startAngle, true); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }); + + ctx.restore(); + ctx.globalAlpha = 1; + + const mainCtx = this.canvas.getContext('2d'); + if (!mainCtx) { + return; + } + mainCtx.clearRect(0, 0, this.canvas.width, this.canvas.height); + mainCtx.drawImage(this.offscreen, 0, 0); + + this.updateSelectionOverlay(); + this.updateScrubOverlay(); + } + + isAgentMatch(event) { + const target = typeof this.props.selectedAgent === 'string' ? this.props.selectedAgent.trim() : ''; + if (!target) { + return true; + } + const normalized = target.toLowerCase(); + const agent = + (typeof event?.agent === 'string' && event.agent) || + (event?.metadata && typeof event.metadata.agent === 'string' ? event.metadata.agent : ''); + if (!agent) { + return false; + } + return agent.toLowerCase() === normalized; + } + + handlePointerMove(event) { + if (!this.canvasRect) { + this.canvasRect = getElementRect(this.canvas); + } + const { left, top, width, height } = this.canvasRect; + const x = event.clientX - left - width / 2; + const y = event.clientY - top - height / 2; + this.lastPointer = { x, y }; + if (this.hoverTimer) { + window.clearTimeout(this.hoverTimer); + this.hoverTimer = null; + } + this.hoverTimer = window.setTimeout(() => { + this.processHoverAtPoint({ x, y }); + }, HOVER_DELAY); + } + + handlePointerLeave() { + if (this.hoverTimer) { + window.clearTimeout(this.hoverTimer); + this.hoverTimer = null; + } + this.state.hoverArc = null; + this.hoverPath.setAttribute('d', ''); + this.hideTooltip(); + } + + processHoverAtPoint(point) { + if (!this.center) { + return; + } + const layout = { + arcs: this.displayArcs.map((arc) => ({ + ...arc, + innerRadius: arc.innerRadius, + outerRadius: arc.outerRadius, + })), + }; + const hovered = findNearestArc(layout, point, { tolerance: 12 }); + if (!hovered) { + this.state.hoverArc = null; + this.hoverPath.setAttribute('d', ''); + this.hideTooltip(); + return; + } + this.state.hoverArc = hovered; + const path = describeSegmentPath( + this.center.x, + this.center.y, + hovered.innerRadius, + hovered.outerRadius, + hovered.startAngle, + hovered.endAngle, + ); + this.hoverPath.setAttribute('d', path); + this.showTooltip(hovered); + } + + handleClick() { + if (this.state.hoverArc) { + this.setSelection(this.state.hoverArc); + } + } + + handleKeyDown(event) { + if (event.key === 'Escape') { + this.clearSelection(); + return; + } + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) { + return; + } + event.preventDefault(); + if (this.visibleArcs.length === 0) { + return; + } + const current = this.state.selectedArc || this.state.hoverArc || this.visibleArcs[0]; + let target = current; + if (event.key === 'ArrowRight') { + target = this.findAdjacentArc(current, +1); + } else if (event.key === 'ArrowLeft') { + target = this.findAdjacentArc(current, -1); + } else if (event.key === 'ArrowUp') { + target = this.findRingShift(current, -1); + } else if (event.key === 'ArrowDown') { + target = this.findRingShift(current, +1); + } + if (target) { + this.setSelection(target); + } + } + + findAdjacentArc(current, delta) { + if (!current) { + return null; + } + const arcs = this.visibleArcs.filter((arc) => arc.ringKey === current.ringKey); + const index = arcs.findIndex((arc) => arc.id === current.id); + if (index === -1) { + return arcs[0] || null; + } + const nextIndex = (index + delta + arcs.length) % arcs.length; + return arcs[nextIndex]; + } + + findRingShift(current, delta) { + if (!current) { + return null; + } + const rings = Array.from(new Set(this.visibleArcs.map((arc) => arc.ringKey))); + const ringIndex = rings.indexOf(current.ringKey); + if (ringIndex === -1) { + return null; + } + const nextRing = rings[clamp(ringIndex + delta, 0, rings.length - 1)]; + const candidates = this.visibleArcs + .filter((arc) => arc.ringKey === nextRing) + .sort((a, b) => Math.abs(a.startMinutes - current.startMinutes) - Math.abs(b.startMinutes - current.startMinutes)); + return candidates[0] || null; + } + + setSelection(arc) { + this.state.selectedArc = arc; + if (typeof this.props.onSelect === 'function') { + this.props.onSelect(arc.event ?? null); + } + const message = arc + ? `${arc.label}, ${minutesToTime(arc.startMinutes)} to ${minutesToTime(arc.startMinutes + arc.duration)}` + : 'Selection cleared'; + this.liveRegion.textContent = message; + this.updateSelectionOverlay(); + } + + clearSelection() { + this.state.selectedArc = null; + this.updateSelectionOverlay(); + } + + showTooltip(arc) { + if (!arc) { + this.hideTooltip(); + return; + } + const html = buildTooltipContent(arc); + this.tooltipMeta.innerHTML = html; + this.tooltip.hidden = false; + const angle = arc.centerAngle; + const radius = (arc.innerRadius + arc.outerRadius) / 2; + const x = this.center.x + Math.cos(angle) * radius; + const y = this.center.y + Math.sin(angle) * radius; + this.tooltip.style.left = `${x + 12}px`; + this.tooltip.style.top = `${y + 12}px`; + } + + hideTooltip() { + this.tooltip.hidden = true; + } + + updateSelectionOverlay() { + const arc = this.state.selectedArc; + if (!arc) { + this.selectionPath.setAttribute('d', ''); + return; + } + const path = describeSegmentPath( + this.center.x, + this.center.y, + arc.innerRadius, + arc.outerRadius, + arc.startAngle, + arc.endAngle, + ); + this.selectionPath.setAttribute('d', path); + } + + updateScrubOverlay() { + const minutes = this.state.scrubMinutes; + const angle = this.mapMinutesToAngle(minutes - this.getZoom().start); + const radius = this.displayMaxRadius ?? (this.layout?.maxRadius ?? 160); + const x2 = this.center.x + Math.cos(angle) * radius; + const y2 = this.center.y + Math.sin(angle) * radius; + this.scrubLine.setAttribute('x1', String(this.center.x)); + this.scrubLine.setAttribute('y1', String(this.center.y)); + this.scrubLine.setAttribute('x2', String(x2)); + this.scrubLine.setAttribute('y2', String(y2)); + } + + setScrub(minutes, { fromPlayback = false } = {}) { + const normalized = moduloMinutes(minutes); + this.state.scrubMinutes = normalized; + this.scrubSlider.value = String(normalized); + this.updateScrubOverlay(); + if (fromPlayback) { + const arc = this.findArcAtMinutes(normalized); + if (arc) { + this.showTooltip(arc); + } + } + } + + findArcAtMinutes(minutes) { + return this.displayArcs.find((arc) => { + const start = moduloMinutes(arc.startMinutes); + const end = moduloMinutes(arc.startMinutes + arc.duration); + if (start <= end) { + return minutes >= start && minutes <= end; + } + return minutes >= start || minutes <= end; + }); + } + + togglePlayback() { + if (this.state.playing) { + this.stopPlayback(); + } else { + this.startPlayback(); + } + } + + setSpeed(speed) { + this.state.playSpeed = speed; + this.refreshModeButtons(); + } + + startPlayback() { + this.state.playing = true; + this.refreshModeButtons(); + this.lastTick = performance.now(); + const tick = (timestamp) => { + if (!this.state.playing) { + return; + } + const deltaMs = timestamp - this.lastTick; + this.lastTick = timestamp; + const deltaMinutes = (deltaMs / 60000) * this.state.playSpeed * (this.getZoom().span / FULL_DAY_MINUTES); + this.setScrub(this.state.scrubMinutes + deltaMinutes, { fromPlayback: true }); + this.frameHandle = window.requestAnimationFrame(tick); + }; + this.frameHandle = window.requestAnimationFrame(tick); + } + + stopPlayback() { + this.state.playing = false; + if (this.frameHandle) { + window.cancelAnimationFrame(this.frameHandle); + this.frameHandle = null; + } + this.refreshModeButtons(); + } + + exportSVG() { + if (!this.layout) { + return ''; + } + const width = this.canvasRect?.width || 512; + const height = this.canvasRect?.height || 512; + const cx = width / 2; + const cy = height / 2; + const arcs = this.displayArcs + .map((arc) => { + const path = describeSegmentPath(cx, cy, arc.innerRadius, arc.outerRadius, arc.startAngle, arc.endAngle); + const fill = resolveStateLayer( + arc.color || mapLabelToColor(arc.label, { highContrast: this.state.highContrast }), + 0.24, + ); + const stroke = arc.color || mapLabelToColor(arc.label, { highContrast: this.state.highContrast }); + return ``; + }) + .join(''); + return `${arcs}`; + } + + async exportPNG() { + if (!this.canvas) { + return null; + } + return new Promise((resolve) => { + this.canvas.toBlob((blob) => { + resolve(blob); + }, 'image/png'); + }); + } +} + +export function createRadialUrchin(root, props) { + return new RadialUrchin(root, props); +} diff --git a/web/ui/visuals/RadialUrchin.tsx b/web/ui/visuals/RadialUrchin.tsx new file mode 100644 index 0000000..0fe44b3 --- /dev/null +++ b/web/ui/visuals/RadialUrchin.tsx @@ -0,0 +1,20 @@ +import type { Schedule } from './useUrchinLayout'; + +export type UrchinMode = 'day-rings' | 'agent-rings'; + +export interface RadialUrchinProps { + data: Schedule | null; + mode?: UrchinMode; + selectedAgent?: string; + onSelect?: (activity: Schedule['events'][number] | null) => void; +} + +export interface RadialUrchinHandle { + exportSVG(): string; + exportPNG(): Promise; + setScrub(minutes: number): void; +} + +export { RadialUrchin, createRadialUrchin } from './RadialUrchin.js'; +export type { Schedule, ScheduleEvent } from './useUrchinLayout'; +export { mapLabelToColor } from './palette.js'; diff --git a/web/ui/visuals/palette.js b/web/ui/visuals/palette.js new file mode 100644 index 0000000..6df1723 --- /dev/null +++ b/web/ui/visuals/palette.js @@ -0,0 +1,65 @@ +const BASE_COLORS = [ + '#6750A4', + '#386A20', + '#00677D', + '#7D5260', + '#815600', + '#0B7285', + '#4E36B1', + '#B02A37', + '#00796B', + '#9C4146', + '#4C6EF5', + '#FF6F61', + '#5C940D', + '#FF8F00', + '#2B8A3E', + '#00A6FB', + '#C77DFF', + '#FFB300', +]; + +const HIGH_CONTRAST_COLORS = [ + '#FFFFFF', + '#F4B400', + '#F45D01', + '#4CAF50', + '#039BE5', + '#E91E63', + '#8E24AA', + '#3949AB', + '#00897B', + '#FB8C00', +]; + +const DARK_SURFACE = '#1C1B1F'; +const LIGHT_SURFACE = '#FDF8FD'; + +function hashLabel(label) { + let hash = 0; + for (let i = 0; i < label.length; i += 1) { + hash = (hash * 31 + label.charCodeAt(i)) & 0xffffffff; + } + return Math.abs(hash); +} + +export function mapLabelToColor(label, { highContrast = false } = {}) { + const palette = highContrast ? HIGH_CONTRAST_COLORS : BASE_COLORS; + if (!label) { + return palette[0]; + } + const hash = hashLabel(label); + return palette[hash % palette.length]; +} + +export function resolveSurface(isDark) { + return isDark ? DARK_SURFACE : LIGHT_SURFACE; +} + +export function resolveStateLayer(color, opacity = 0.12) { + const normalized = opacity < 0 ? 0 : opacity > 1 ? 1 : opacity; + const alpha = Math.round(normalized * 255) + .toString(16) + .padStart(2, '0'); + return `${color}${alpha}`; +} diff --git a/web/ui/visuals/useUrchinLayout.js b/web/ui/visuals/useUrchinLayout.js new file mode 100644 index 0000000..b2c646d --- /dev/null +++ b/web/ui/visuals/useUrchinLayout.js @@ -0,0 +1,237 @@ +import { mapLabelToColor } from './palette.js'; + +const FULL_DAY_MINUTES = 24 * 60; +const TAU = Math.PI * 2; + +function parseTimeToMinutes(value) { + if (typeof value !== 'string') { + return 0; + } + const [hours, minutes] = value.split(':').map((part) => Number.parseInt(part, 10)); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) { + return 0; + } + return (hours * 60 + minutes) % FULL_DAY_MINUTES; +} + +function normaliseDuration(start, end) { + if (end >= start) { + return end - start; + } + return FULL_DAY_MINUTES - start + end; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function minutesToAngle(minutes) { + return (minutes / FULL_DAY_MINUTES) * TAU - Math.PI / 2; +} + +function computeRingKey(event, mode) { + if (mode === 'agent-rings') { + if (event.agent) { + return { key: event.agent, label: event.agent, sortKey: event.agent.toLowerCase() }; + } + if (event.metadata && typeof event.metadata.agent === 'string') { + const label = event.metadata.agent; + return { key: label, label, sortKey: label.toLowerCase() }; + } + if (event.activity) { + return { key: event.activity, label: event.activity, sortKey: event.activity.toLowerCase() }; + } + } + const label = event.date || 'Unknown'; + return { key: label, label, sortKey: label }; +} + +function normaliseEvents(events) { + return events + .map((event, index) => ({ + ...event, + _index: index, + })) + .filter((event) => typeof event.start === 'string' && typeof event.end === 'string'); +} + +export function groupEventsByRing(events, mode) { + const grouped = new Map(); + events.forEach((event) => { + const ring = computeRingKey(event, mode); + if (!grouped.has(ring.key)) { + grouped.set(ring.key, { key: ring.key, label: ring.label, sortKey: ring.sortKey, events: [] }); + } + grouped.get(ring.key).events.push(event); + }); + return Array.from(grouped.values()).sort((a, b) => a.sortKey.localeCompare(b.sortKey)); +} + +export function computeLabelTotals(events) { + const totals = new Map(); + events.forEach((event) => { + const label = event.label || event.activity || 'Activity'; + const start = parseTimeToMinutes(event.start); + const end = parseTimeToMinutes(event.end); + const duration = normaliseDuration(start, end); + const previous = totals.get(label) || 0; + totals.set(label, previous + duration); + }); + return Array.from(totals.entries()) + .map(([label, minutes]) => ({ label, minutes })) + .sort((a, b) => b.minutes - a.minutes); +} + +export function computeUrchinLayout(schedule, options = {}) { + const mode = options.mode || 'day-rings'; + const ringWidth = options.ringWidth || 34; + const ringGap = options.ringGap || 12; + const baseRadius = options.baseRadius || 48; + const includeLabels = options.includeLabels ?? (() => true); + const highContrast = Boolean(options.highContrast); + + const events = normaliseEvents(Array.isArray(schedule?.events) ? schedule.events : []); + const totals = computeLabelTotals(events); + + const grouped = groupEventsByRing(events, mode); + const ringEntries = []; + const arcs = []; + + grouped.forEach((group, ringIndex) => { + const innerRadius = baseRadius + ringIndex * (ringWidth + ringGap); + const outerRadius = innerRadius + ringWidth; + const ringArcs = []; + + group.events + .map((event) => { + const label = event.label || event.activity || 'Activity'; + const startMinutes = parseTimeToMinutes(event.start); + const endMinutes = parseTimeToMinutes(event.end); + const duration = normaliseDuration(startMinutes, endMinutes); + return { + id: `${group.key}:${event._index}`, + event, + ringKey: group.key, + ringIndex, + label, + startMinutes, + endMinutes, + duration, + color: mapLabelToColor(label, { highContrast }), + innerRadius, + outerRadius, + startAngle: minutesToAngle(startMinutes), + endAngle: minutesToAngle((startMinutes + duration) % FULL_DAY_MINUTES), + }; + }) + .sort((a, b) => a.startMinutes - b.startMinutes) + .forEach((arc) => { + if (!includeLabels(arc.label)) { + return; + } + // Ensure arcs respect direction when wrapping past midnight. + let endAngle = arc.endAngle; + if (arc.duration > 0 && arc.startMinutes + arc.duration >= FULL_DAY_MINUTES) { + endAngle = arc.startAngle + (arc.duration / FULL_DAY_MINUTES) * TAU; + } + const normalizedArc = { + ...arc, + startAngle: arc.startAngle, + endAngle, + centerAngle: arc.startAngle + (endAngle - arc.startAngle) / 2, + }; + arcs.push(normalizedArc); + ringArcs.push(normalizedArc); + }); + + ringEntries.push({ + key: group.key, + label: group.label, + index: ringIndex, + innerRadius, + outerRadius, + arcs: ringArcs, + }); + }); + + const maxRadius = ringEntries.length + ? ringEntries[ringEntries.length - 1].outerRadius + ringGap + : baseRadius + ringWidth; + + return { + arcs, + rings: ringEntries, + totals, + maxRadius, + mode, + }; +} + +export function findNearestArc(layout, point, options = {}) { + if (!layout || !Array.isArray(layout.arcs)) { + return null; + } + const tolerance = clamp(options.tolerance ?? 8, 2, 40); + const { x, y } = point; + const radius = Math.sqrt(x * x + y * y); + const angle = Math.atan2(y, x); + const normalizedAngle = angle < -Math.PI / 2 ? angle + TAU : angle; + + let best = null; + let bestScore = Infinity; + + layout.arcs.forEach((arc) => { + if (radius < arc.innerRadius - tolerance || radius > arc.outerRadius + tolerance) { + return; + } + let { startAngle, endAngle } = arc; + if (endAngle < startAngle) { + endAngle += TAU; + } + let targetAngle = normalizedAngle; + let adjustedAngle = targetAngle; + if (targetAngle < startAngle) { + adjustedAngle = startAngle; + } else if (targetAngle > endAngle) { + adjustedAngle = endAngle; + } + const angleDist = Math.abs(targetAngle - adjustedAngle); + const radialCenter = (arc.innerRadius + arc.outerRadius) / 2; + const radialDist = Math.abs(radius - radialCenter); + const score = angleDist * radialCenter + radialDist * 0.5; + if (score < bestScore) { + best = { arc, angle: targetAngle, radius, score }; + bestScore = score; + } + }); + + if (!best) { + return null; + } + + const withinTolerance = Math.sqrt(best.score) <= tolerance; + if (!withinTolerance) { + return null; + } + + return best.arc; +} + +export function formatDuration(minutes) { + const hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + if (hours === 0) { + return `${remainder}m`; + } + if (remainder === 0) { + return `${hours}h`; + } + return `${hours}h ${remainder}m`; +} + +export function minutesToTime(minutes) { + const normalized = ((minutes % FULL_DAY_MINUTES) + FULL_DAY_MINUTES) % FULL_DAY_MINUTES; + const hours = Math.floor(normalized / 60); + const mins = normalized % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} diff --git a/web/ui/visuals/useUrchinLayout.ts b/web/ui/visuals/useUrchinLayout.ts new file mode 100644 index 0000000..97eb6a7 --- /dev/null +++ b/web/ui/visuals/useUrchinLayout.ts @@ -0,0 +1,50 @@ +export interface ScheduleEvent { + date: string; + start: string; + end: string; + label: string; + activity?: string; + agent?: string; + metadata?: Record; +} + +export interface Schedule { + schema_version: string; + week_start: string; + events: ScheduleEvent[]; + metadata?: Record; +} + +export interface LabelTotal { + label: string; + minutes: number; +} + +export interface UrchinArc { + id: string; + label: string; + startMinutes: number; + duration: number; + innerRadius: number; + outerRadius: number; + color: string; + ringIndex: number; + ringKey: string; + event: ScheduleEvent; +} + +export interface UrchinLayout { + arcs: UrchinArc[]; + rings: Array<{ + key: string; + label: string; + index: number; + innerRadius: number; + outerRadius: number; + }>; + totals: LabelTotal[]; + maxRadius: number; + mode: 'day-rings' | 'agent-rings'; +} + +export { computeUrchinLayout, computeLabelTotals, groupEventsByRing, findNearestArc, formatDuration, minutesToTime } from './useUrchinLayout.js';