diff --git a/web/app.js b/web/app.js index 360113a..04cebc5 100644 --- a/web/app.js +++ b/web/app.js @@ -96,6 +96,7 @@ const visualsState = { statusOverlay: null, statusText: null, metaBar: null, + metaSlot: null, runLabel: null, }; let lastVisualPayload = null; @@ -318,6 +319,12 @@ function updateVisuals(payload) { } try { + if (visualsState.metaBar && typeof visualsState.urchin.attachRunMeta === 'function') { + visualsState.urchin.attachRunMeta(visualsState.metaBar); + } + if (visualsState.metaSlot) { + visualsState.metaSlot.hidden = Boolean(visualsState.metaBar?.hidden); + } visualsState.urchin.update({ data: lastVisualSchedule }); } catch (error) { console.error('[visuals] failed to update radial urchin:', error); @@ -335,6 +342,10 @@ function resetVisualsInstance() { } visualsState.urchin = null; } + if (visualsState.metaBar && visualsState.metaBar.parentElement) { + visualsState.metaBar.parentElement.removeChild(visualsState.metaBar); + } + visualsState.metaSlot = null; if (visualsState.mount && visualsState.mount.childNodes.length > 0) { visualsState.mount.replaceChildren(); } @@ -374,6 +385,14 @@ function maybeCreateUrchinInstance(schedule) { onSelect: handleUrchinSelect, }); if (instance) { + if (typeof instance.getRunMetaSlot === 'function') { + visualsState.metaSlot = instance.getRunMetaSlot(); + } else { + visualsState.metaSlot = null; + } + if (visualsState.metaBar && typeof instance.attachRunMeta === 'function') { + instance.attachRunMeta(visualsState.metaBar); + } visualsState.urchin = instance; } } @@ -396,17 +415,16 @@ function initVisualsMount() { visualsState.layout = layout; const mainPanel = document.createElement('div'); - mainPanel.className = 'visuals-main'; + mainPanel.className = 'visuals-main visuals-panel'; layout.append(mainPanel); visualsState.mainPanel = mainPanel; const metaBar = document.createElement('div'); - metaBar.className = 'visuals-meta'; + metaBar.className = 'visuals-run-meta'; metaBar.hidden = true; const runLabel = document.createElement('span'); - runLabel.className = 'visuals-meta__run'; + runLabel.className = 'visuals-run-meta__label'; metaBar.append(runLabel); - mainPanel.append(metaBar); visualsState.metaBar = metaBar; visualsState.runLabel = runLabel; @@ -614,6 +632,9 @@ function updateActiveRunLabel() { if (!activeId) { runLabel.textContent = ''; metaBar.hidden = true; + if (visualsState.metaSlot) { + visualsState.metaSlot.hidden = true; + } return; } @@ -621,6 +642,9 @@ function updateActiveRunLabel() { if (index === -1) { runLabel.textContent = ''; metaBar.hidden = true; + if (visualsState.metaSlot) { + visualsState.metaSlot.hidden = true; + } return; } @@ -635,6 +659,9 @@ function updateActiveRunLabel() { } runLabel.textContent = parts.join(' ยท '); metaBar.hidden = false; + if (visualsState.metaSlot) { + visualsState.metaSlot.hidden = false; + } } function renderCalendarRunHistory() { diff --git a/web/style.css b/web/style.css index a99086b..4373f85 100644 --- a/web/style.css +++ b/web/style.css @@ -11,6 +11,7 @@ body { background: #121318; display: flex; justify-content: center; + min-height: 100vh; padding: 16px; } @@ -23,6 +24,9 @@ body { overflow: hidden; display: flex; flex-direction: column; + height: calc(100vh - 32px); + min-height: calc(100vh - 32px); + max-height: calc(100vh - 32px); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35); } @@ -154,7 +158,11 @@ body { z-index: 0; padding: 12px; background: #181a21; - min-height: 420px; + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; } .tab-panel { @@ -500,13 +508,28 @@ body { outline-offset: 2px; } +#panel-visuals { + flex: 1; + min-height: 0; + overflow: hidden; +} + +#panel-visuals.tab-panel.active { + display: flex; + flex-direction: column; +} + +#panel-visuals .tab-panel-title { + flex-shrink: 0; +} + #visuals-container { position: relative; z-index: 0; display: flex; flex-direction: column; - min-height: 420px; - height: 100%; + flex: 1; + min-height: 0; border-radius: 20px; background: linear-gradient(180deg, rgba(15, 23, 42, 0.75), rgba(15, 23, 42, 0.3)); padding: 20px; @@ -517,40 +540,82 @@ body { .visuals-layout { flex: 1; display: flex; - gap: 20px; + flex-direction: column; + gap: 16px; min-height: 0; } .visuals-main { flex: 1; min-width: 0; + min-height: 0; display: flex; flex-direction: column; gap: 16px; position: relative; } -.visuals-meta { +.visuals-panel { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; +} + +.visuals-header-row { display: flex; align-items: center; justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.visuals-controls-block { + display: flex; + flex-direction: column; + gap: 12px; + align-items: stretch; + flex: 0 0 auto; +} + +.visuals-run-meta { + display: inline-flex; + align-items: center; gap: 8px; - padding: 8px 12px; - border-radius: 12px; + padding: 6px 12px; + border-radius: 999px; border: 1px solid rgba(148, 163, 184, 0.25); background: rgba(15, 23, 42, 0.45); color: #e2e8f0; - font-size: 13px; - font-weight: 500; + font-size: 12px; + font-weight: 600; letter-spacing: 0.01em; + white-space: nowrap; } -.visuals-meta[hidden] { +.visuals-run-meta[hidden] { display: none; } -.visuals-meta__run { - font-weight: 600; +.visuals-run-meta__label { + font-variant-numeric: tabular-nums; +} + +.visuals-canvas-block { + flex: 1; + min-height: 0; +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; + max-height: 96px; + overflow-y: auto; + overflow-x: hidden; + padding: 4px 4px 4px 0; } .visuals-history-panel { @@ -932,7 +997,9 @@ body { position: relative; z-index: 0; flex: 1; - min-height: 420px; + min-height: 0; + display: flex; + flex-direction: column; } .visuals-fallback-panel { @@ -1022,20 +1089,23 @@ body { gap: 16px; color: #f8fafc; font-family: 'Inter', 'SF Pro Text', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + flex: 1; + min-height: 0; } .radial-urchin { display: flex; flex-direction: column; gap: 16px; + flex: 1; + min-height: 0; } .radial-urchin__controls { display: flex; - flex-wrap: wrap; - gap: 16px; - align-items: center; - justify-content: space-between; + flex-direction: column; + gap: 12px; + align-items: stretch; background: rgba(15, 23, 42, 0.65); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 18px; @@ -1043,6 +1113,24 @@ body { box-shadow: 0 22px 36px rgba(15, 23, 42, 0.38); } +.radial-urchin__header { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + width: 100%; +} + +.radial-urchin__meta-slot { + margin-left: auto; + display: flex; + align-items: center; +} + +.radial-urchin__meta-slot[hidden] { + display: none; +} + .radial-urchin__segmented { display: inline-flex; align-items: center; @@ -1079,13 +1167,15 @@ body { display: flex; flex-wrap: wrap; gap: 8px; - flex: 1 1 auto; + width: 100%; + align-items: flex-start; } .radial-urchin__legend-empty { margin: 0; color: #94a3b8; font-size: 13px; + width: 100%; } .radial-urchin__chip { @@ -1115,7 +1205,10 @@ body { .radial-urchin__actions { display: flex; align-items: center; + flex-wrap: wrap; gap: 12px; + justify-content: flex-start; + width: 100%; } .radial-urchin__icon-button { @@ -1145,6 +1238,7 @@ body { .radial-urchin__speed { display: inline-flex; gap: 4px; + flex-wrap: wrap; background: rgba(51, 65, 85, 0.65); border-radius: 999px; padding: 4px; @@ -1169,7 +1263,8 @@ body { .radial-urchin__scrub { appearance: none; - width: 180px; + flex: 1 1 160px; + min-width: 160px; height: 4px; border-radius: 999px; background: linear-gradient(90deg, rgba(99, 102, 241, 0.65), rgba(34, 211, 238, 0.65)); @@ -1216,6 +1311,7 @@ body { .radial-urchin__stage { position: relative; + flex: 1; min-height: 360px; border-radius: 20px; background: radial-gradient(circle at top, rgba(148, 163, 184, 0.15), rgba(15, 23, 42, 0.75)); diff --git a/web/ui/visuals/RadialUrchin.js b/web/ui/visuals/RadialUrchin.js index 6be3a9e..07c38b9 100644 --- a/web/ui/visuals/RadialUrchin.js +++ b/web/ui/visuals/RadialUrchin.js @@ -140,6 +140,9 @@ export class RadialUrchin { this.didWarnNoData = false; this.didWarnInvalidCenter = false; + this.metaElement = null; + this.metaSlot = null; + this.handleResize = this.handleResize.bind(this); this.handlePointerMove = this.handlePointerMove.bind(this); this.handlePointerLeave = this.handlePointerLeave.bind(this); @@ -173,12 +176,21 @@ export class RadialUrchin { this.root.append(this.container); this.controlBar = document.createElement('div'); - this.controlBar.className = 'radial-urchin__controls'; + this.controlBar.className = 'radial-urchin__controls visuals-controls-block'; this.container.append(this.controlBar); + this.headerRow = document.createElement('div'); + this.headerRow.className = 'radial-urchin__header visuals-header-row'; + this.controlBar.append(this.headerRow); + this.modeControl = document.createElement('div'); this.modeControl.className = 'radial-urchin__segmented'; - this.controlBar.append(this.modeControl); + this.headerRow.append(this.modeControl); + + this.metaSlot = document.createElement('div'); + this.metaSlot.className = 'radial-urchin__meta-slot'; + this.metaSlot.hidden = true; + this.headerRow.append(this.metaSlot); this.modeButtons = new Map(); Object.entries(MODE_LABELS).forEach(([mode, label]) => { @@ -195,7 +207,7 @@ export class RadialUrchin { }); this.legendContainer = document.createElement('div'); - this.legendContainer.className = 'radial-urchin__legend'; + this.legendContainer.className = 'radial-urchin__legend chip-row'; this.controlBar.append(this.legendContainer); this.actionsContainer = document.createElement('div'); @@ -296,7 +308,7 @@ export class RadialUrchin { ); this.canvasWrapper = document.createElement('div'); - this.canvasWrapper.className = 'radial-urchin__stage'; + this.canvasWrapper.className = 'radial-urchin__stage visuals-canvas-block'; this.container.append(this.canvasWrapper); this.canvas = document.createElement('canvas'); @@ -381,7 +393,39 @@ export class RadialUrchin { } } + getRunMetaSlot() { + return this.metaSlot || null; + } + + attachRunMeta(element) { + if (!this.metaSlot) { + return; + } + if (!(element instanceof HTMLElement)) { + this.detachRunMeta(); + this.metaSlot.hidden = true; + return; + } + if (this.metaElement !== element || element.parentElement !== this.metaSlot) { + this.detachRunMeta(); + this.metaElement = element; + this.metaSlot.append(element); + } + this.metaSlot.hidden = Boolean(element.hidden); + } + + detachRunMeta() { + if (this.metaElement && this.metaElement.parentElement === this.metaSlot) { + this.metaSlot.removeChild(this.metaElement); + } + this.metaElement = null; + if (this.metaSlot) { + this.metaSlot.hidden = true; + } + } + destroy() { + this.detachRunMeta(); this.stopPlayback(); if (this.resizeObserver) { this.resizeObserver.disconnect();