diff --git a/e2e/overflow.spec.ts b/e2e/overflow.spec.ts index 8b31716..393ae53 100644 --- a/e2e/overflow.spec.ts +++ b/e2e/overflow.spec.ts @@ -112,6 +112,11 @@ for (const bp of breakpoints.filter((bp) => bp.width <= 768)) { test('twilight rail supports pointer scrubbing', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); + // The first-visit tour card overlays the dock rail at this viewport (the + // helix strip is taller than the old gantt bar, so the rail's center line + // sits under the card); seed the tour-seen flag so the drag reaches the + // slider (the aq-dashboard spec's idiom — clicking Skip races tour mount). + await page.addInitScript(() => localStorage.setItem('darkmap-tour-v1', '1')); await page.goto('/'); await page.waitForLoadState('networkidle'); const bar = page.locator('.gantt .bar'); diff --git a/e2e/twilight-compact-webkit.spec.ts b/e2e/twilight-compact-webkit.spec.ts index dcab93f..314eab4 100644 --- a/e2e/twilight-compact-webkit.spec.ts +++ b/e2e/twilight-compact-webkit.spec.ts @@ -1,34 +1,72 @@ import { expect, test } from '@playwright/test'; /** - * Bug B — the mobile twilight bar (EphemerisGantt) renders wrong at COMPACT. - * OBSERVE-FIRST: the gantt auto-opens post-mount (ephemerisOpen=true) and lives in - * the dock's `.dock-gantt-row`. This spec measures its box + the twilight stripes - * and screenshots — first run = symptom truth (collapsed? stripeless? fine?), then - * the gate. Run under webkit-mobile AND chromium-mobile for the engine differential. + * H1 — the TimeHelix (the twilight gantt's successor) renders the now-centered + * helical ribbon at COMPACT. The helix auto-opens post-mount (ephemerisOpen=true) + * and lives in the dock's `.dock-gantt-row`. This spec measures its box, the + * phase-colored ribbon segments, and the now-centering acceptance (un-scrubbed + * mount = cursor at now = the `[data-now-marker]` staff sits in the central third + * of the scrub bar). Run under webkit-mobile AND chromium-mobile for the engine + * differential. * * Run: PLAYWRIGHT_ALL_BROWSERS=1 pnpm exec playwright test --project=webkit-mobile e2e/twilight-compact-webkit.spec.ts */ -test('B1: twilight gantt renders non-collapsed with stripes at COMPACT', async ({ page }, testInfo) => { +test('H1: time helix renders now-centered with phase-colored ribbon at COMPACT', async ({ page }, testInfo) => { await page.goto('/'); await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2500); // ephemeris auto-open + the readout/center data land + await page.waitForTimeout(2500); // ephemeris auto-open + the per-day readouts land const gantt = page.locator('.dock-gantt-row .gantt').first(); const present = await gantt.count(); const box = present ? await gantt.boundingBox().catch(() => null) : null; - const stripeCount = await page - .locator('.dock-gantt-row .gantt .bar .stripe') - .count() - .catch(() => 0); const barBox = await page .locator('.dock-gantt-row .gantt .bar') .first() .boundingBox() .catch(() => null); + const stripes = page.locator('.dock-gantt-row .gantt [data-phase]'); + const stripeCount = await stripes.count().catch(() => 0); + const distinctPhases = await stripes + .evaluateAll((els) => + Array.from( + new Set( + els + .filter((el) => el.getClientRects().length > 0) + .map((el) => el.getAttribute('data-phase')) + .filter((p): p is string => p !== null), + ), + ), + ) + .catch(() => [] as string[]); + const markerBox = await page + .locator('.dock-gantt-row .gantt [data-now-marker]') + .first() + .boundingBox() + .catch(() => null); + const phaseUnderNow = await page + .evaluate(() => { + const marker = document.querySelector('.dock-gantt-row .gantt [data-now-marker]'); + if (!marker) return null; + const m = marker.getBoundingClientRect(); + const cx = m.left + m.width / 2; + let best: string | null = null; + let bestDist = Infinity; + for (const el of document.querySelectorAll('.dock-gantt-row .gantt .stripe')) { + const r = el.getBoundingClientRect(); + const dist = Math.abs((r.left + r.right) / 2 - cx); + if (dist < bestDist) { + bestDist = dist; + best = el.getAttribute('data-phase'); + } + } + return best; + }) + .catch(() => null); console.log( - `[B1 ${testInfo.project.name}] gantt present=${present} box=${JSON.stringify(box)} bar=${JSON.stringify(barBox)} stripes=${stripeCount}`, + `[H1 ${testInfo.project.name}] helix present=${present} box=${JSON.stringify(box)} bar=${JSON.stringify(barBox)} ` + + `stripes=${stripeCount} phases=${JSON.stringify(distinctPhases)} nowMarker=${JSON.stringify(markerBox)} ` + + `phaseUnderNow=${phaseUnderNow}`, ); await page .screenshot({ @@ -38,9 +76,21 @@ test('B1: twilight gantt renders non-collapsed with stripes at COMPACT', async ( }) .catch(() => {}); - // CORRECT behaviour (red = the reported "not displaying properly"): - expect(box, 'the twilight gantt is present in the dock row at COMPACT').not.toBeNull(); - expect(box!.height, 'gantt is not collapsed (height)').toBeGreaterThan(20); - expect(box!.width, 'gantt has real width').toBeGreaterThan(100); - expect(stripeCount, 'the twilight bands (stripes) actually render').toBeGreaterThan(0); + // CORRECT behaviour: + expect(box, 'the time helix is present in the dock row at COMPACT').not.toBeNull(); + expect(box!.height, 'helix is not collapsed (height)').toBeGreaterThan(20); + expect(box!.width, 'helix has real width').toBeGreaterThan(100); + expect(stripeCount, 'the ribbon segments actually render').toBeGreaterThan(0); + expect( + distinctPhases.length, + 'the ribbon is phase-colored (≥2 distinct data-phase values — not just neutral placeholders)', + ).toBeGreaterThanOrEqual(2); + + // Now-centering acceptance: un-scrubbed mount = cursor at now, so the now + // staff must sit in the central third of the scrub bar. + expect(barBox, 'the scrub bar is measurable').not.toBeNull(); + expect(markerBox, 'the now staff ([data-now-marker]) renders').not.toBeNull(); + const markerCx = markerBox!.x + markerBox!.width / 2; + expect(markerCx, 'now staff right of the central-third left bound').toBeGreaterThan(barBox!.x + barBox!.width / 3); + expect(markerCx, 'now staff left of the central-third right bound').toBeLessThan(barBox!.x + (2 * barBox!.width) / 3); }); diff --git a/src/lib/components/EphemerisGantt.svelte b/src/lib/components/EphemerisGantt.svelte deleted file mode 100644 index 674d199..0000000 --- a/src/lib/components/EphemerisGantt.svelte +++ /dev/null @@ -1,918 +0,0 @@ - - -
-
- {fmtDate(dayStart)} UTC - - {#snippet trigger()} - - cursor {fmtClock(time)} - {#if cursorPhase} - - {cursorPhase.label.toLowerCase()} - {/if} - - {/snippet} - - {#if nowPhase} - - {#snippet trigger()} - - - now {fmtClock(now)} - - {/snippet} - - {/if} - {#if rangeBadge} - - {#snippet trigger()} - {rangeBadge.label} - {/snippet} - - {/if} - {#if closestRange} - - {#snippet trigger()} - - {labelFor(closestRange.key)} - {fmtClock(closestRange.range.min)}–{fmtClock(closestRange.range.max)} - {fmtDelta((closestRange.range.max.getTime() - closestRange.range.min.getTime()) / 60_000)} - - {/snippet} - {#snippet content()} -
- Event-time spread across the visible viewport. -
- Sampled at 16 points across the current tile-cover summary. Small pans inside that cover reuse the same cache - entry; selected pins and GPS fixes stay point-precise. -
- {/snippet} -
- {/if} - {#if readout} - {@const moon = readout.moon} - - {#snippet trigger()} - - {moon.phaseName} · {(moon.illumination * 100).toFixed(0)}% - - {/snippet} - - {:else if loading} - - {/if} -
-
- {#each stripes as s (s.key)} - - {/each} - {#if sunriseFrac !== null} - - {/if} - {#if noonFrac !== null} - - {/if} - {#if sunsetFrac !== null} - - {/if} - {#if nowFrac !== null} - - {/if} - -
- - {#if readout} -
- {#each ['astronomicalDawn', 'nauticalDawn', 'civilDawn', 'sunrise', 'solarNoon', 'sunset', 'civilDusk', 'nauticalDusk', 'astronomicalDusk'] as evKey, i (evKey)} - {@const tip = eventTipFor(evKey as EphemerisEventKey)} - {@const at = readout.events[evKey as EphemerisEventKey]} - {#if i === 3 || i === 6} - · - {/if} - - {#snippet trigger()} - {tip.short} {fmtClock(at)} - {/snippet} - - {/each} -
-
- {#each Array.from(new Set(phaseSegments.map((s) => s.name))) as phaseName (phaseName)} - {@const def = PHASE_DEFINITIONS[phaseName]} - - {#snippet trigger()} - - - {def.label.toLowerCase()} - - {/snippet} - - {/each} -
- {/if} -
- - diff --git a/src/lib/components/TimeHelix.svelte b/src/lib/components/TimeHelix.svelte new file mode 100644 index 0000000..1ad86f5 --- /dev/null +++ b/src/lib/components/TimeHelix.svelte @@ -0,0 +1,850 @@ + + + + +
+
+ + {#snippet trigger()} + + {fmtClockLocal(time)} + + + {/snippet} + + {#if !coincident} + + {/if} + + + {dateSpanLabel} + UTC {fmtClockUtc(time)} + + + {#if cursorDayReadout} + {@const moon = cursorDayReadout.moon} + + {#snippet trigger()} + + {moon.phaseName} · {(moon.illumination * 100).toFixed(0)}% + + {/snippet} + + {:else if loading} + + {/if} + +
+
+
+ {#if geom && scales} + + {/if} +
+ +
+ {#each noduleViews as nv (nv.id)} + {@const d = new Date(nv.n.tMs)} +
+ + + + {#snippet element(attributes)} +
+ +
+ {nv.n.tip.long} + {fmtClockLocal(d)} local · {fmtClockUtc(d)} UTC +
+ {nv.n.tip.description} +
+
+
+ {/snippet} +
+
+
+ {/each} +
+
+
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 90c31b3..59cba3c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -96,7 +96,7 @@ X, } from '@lucide/svelte'; import Tour, { type TourStep } from '$lib/components/Tour.svelte'; - import EphemerisGantt from '$lib/components/EphemerisGantt.svelte'; + import TimeHelix from '$lib/components/TimeHelix.svelte'; import GeocoderSearch from '$lib/components/GeocoderSearch.svelte'; import LayerRail, { type LayerState } from '$lib/components/LayerRail.svelte'; import LensSwitcher from '$lib/components/LensSwitcher.svelte'; @@ -3080,7 +3080,7 @@ {#snippet ganttBlock()} {#if ephemerisOpen} -