11<script setup lang="ts">
2- // The SVG is loaded as a raw string (Vite `?raw` import) and rendered
3- // via v-html so that the inline <style> tag inside the SVG (which
4- // carries the @keyframes for the chunked-cube reveal) survives Vue's
5- // template compiler. v-pre alone does NOT prevent the compiler from
6- // stripping <style>/<script> tags from templates, so we have to bypass
7- // template compilation entirely.
2+ // The chunked-cube SVG lives at /public/zarr-hero.svg and is loaded
3+ // via fetch() on mount. Two reasons:
4+ // 1. Keeps the home page's inline HTML payload small — the SVG is
5+ // ~60 KB and was previously inlined via `?raw` + v-html.
6+ // 2. Lets the SVG's inline <style> block (which carries the @keyframes
7+ // for the chunk reveal) survive into the DOM — fetch + innerHTML
8+ // preserves <style> tags exactly.
9+ //
10+ // The host div uses `v-once` so Vue does not try to reconcile against
11+ // the imperatively-injected SVG. The popup is a sibling, not a child,
12+ // so Vue still owns that branch.
813//
914// Reveal: ~3s one-shot. Each chunk has class="c cN"; the cN class drives
1015// its individual chunkIn_N keyframe (opacity 0 → 1) and a SMIL translate
1116// (off-screen → in-place). After the reveal, chunks settle.
1217//
13- // Interactivity: hovering a chunk dims the rest of the cube and shows
14- // a small callout giving that chunk's address (chunk[x,y,z], derived
15- // from its index in the 3×3×3 grid) and a deterministic 4×4 mini-grid
16- // representing its "data" . Conveys Zarr's value : chunks are
17- // independently addressable and each one carries its own contents.
18+ // Interactivity: hovering a chunk dims the rest and surfaces a callout
19+ // with the chunk's address (chunk[z, y, x] derived from visual rank in
20+ // the 3×3×3 grid), shape, and codec. Click/tap selects; tapping outside
21+ // the cube dismisses . Conveys: chunks are independently addressable and
22+ // each one carries its own compressed contents.
1823//
19- // Click/tap selects the chunk; tapping outside the cube dismisses.
20- // SSR-safe: handlers attach only on mount.
24+ // SSR-safe: handlers and SVG injection happen only on mount.
2125
2226import { ref , computed , onMounted , onBeforeUnmount } from ' vue'
23- import svg from ' ./zarr-hero.svg?raw '
27+ import { withBase } from ' vitepress '
2428
2529interface Hovered {
2630 index: number
2731 rect: DOMRect
2832}
2933
3034const wrap = ref <HTMLDivElement | null >(null )
35+ const svgHost = ref <HTMLDivElement | null >(null )
3136const hovered = ref <Hovered | null >(null )
3237
3338let currentChunk: Element | null = null
@@ -40,8 +45,8 @@ let cleanup: (() => void) | null = null
4045let visualRank = new WeakMap <Element , number >()
4146let visualRankBuilt = false
4247
43- function buildVisualRank(root : HTMLElement ): void {
44- const chunks = Array .from (root .querySelectorAll <Element >(' .c' ))
48+ function buildVisualRank(host : HTMLElement ): void {
49+ const chunks = Array .from (host .querySelectorAll <Element >(' .c' ))
4550 const sorted = chunks
4651 .map ((el ) => ({ el , r: el .getBoundingClientRect () }))
4752 .sort ((a , b ) => a .r .top - b .r .top || a .r .left - b .r .left )
@@ -55,45 +60,71 @@ function rankCoords(rank: number): [number, number, number] {
5560 return [Math .floor (rank / 9 ), Math .floor (rank / 3 ) % 3 , rank % 3 ]
5661}
5762
58- // Deterministic per-chunk compression stats. Same seed each hover, so
59- // the same chunk shows the same numbers — emphasizes that chunks are
60- // independent units with their own compressed payloads, and that
61- // compression ratios vary with the data each chunk happens to hold.
62- function chunkStats(rank : number ): { ratio: string } {
63- let seed = (rank + 1 ) * 16807
64- seed = (seed * 1103515245 + 12345 ) & 0x7fffffff
65- // 4× – 12×: typical for scientific arrays with zstd + shuffle, the
66- // pattern used by ARCO-ERA5, CMIP6 cloud archives, and most production
67- // Zarr stores. Conservative end of what's actually seen in practice;
68- // sparse / quantized data routinely hits 20×+ but we keep the range
69- // tight so the popup reads as a credible default, not a brag.
70- const r = 4 + ((seed % 1000 ) / 1000 ) * 8
71- return { ratio: r .toFixed (1 ) }
72- }
73-
7463const coords = computed (() =>
7564 hovered .value ? rankCoords (hovered .value .index ).join (' , ' ) : ' ' ,
7665)
77- const stats = computed (() =>
78- hovered .value ? chunkStats (hovered .value .index ) : { ratio: ' ' },
79- )
8066
67+ // Popup positioning with edge detection. The popup sits 14 px to the
68+ // right of the chunk by default. If that would push it past the viewport
69+ // right edge (e.g. on a narrow mobile viewport), we flip to the left of
70+ // the chunk; if neither fits, we center horizontally in the viewport.
71+ // Vertical: top-align with the chunk; flip below if it would overflow.
8172const calloutStyle = computed (() => {
8273 if (! hovered .value || ! wrap .value ) return {}
8374 const w = wrap .value .getBoundingClientRect ()
8475 const r = hovered .value .rect
85- return {
86- left: ` ${r .right - w .left + 14 }px ` ,
87- top: ` ${r .top - w .top - 8 }px ` ,
76+
77+ // Estimated callout dimensions (matches the rendered min-width and
78+ // typical content height; close enough for placement decisions).
79+ const POPUP_W = 200
80+ const POPUP_H = 90
81+ const GAP = 14
82+ const MARGIN = 8
83+
84+ const vw = typeof window !== ' undefined' ? window .innerWidth : 1200
85+ const vh = typeof window !== ' undefined' ? window .innerHeight : 800
86+
87+ let left: number
88+ if (r .right + GAP + POPUP_W <= vw - MARGIN ) {
89+ left = r .right - w .left + GAP
90+ } else if (r .left - GAP - POPUP_W >= MARGIN ) {
91+ left = r .left - w .left - GAP - POPUP_W
92+ } else {
93+ // Center horizontally in viewport (very narrow screens).
94+ left = vw / 2 - w .left - POPUP_W / 2
95+ }
96+
97+ let top: number
98+ if (r .top + POPUP_H <= vh - MARGIN ) {
99+ top = r .top - w .top - 8
100+ } else {
101+ top = r .bottom - w .top + 8
88102 }
103+
104+ return { left: ` ${left }px ` , top: ` ${top }px ` }
89105})
90106
91- onMounted (() => {
107+ onMounted (async () => {
92108 const root = wrap .value
93- if (! root ) return
109+ const host = svgHost .value
110+ if (! root || ! host ) return
111+
112+ // Fetch and inject the SVG. innerHTML preserves the inline <style>
113+ // block carrying @keyframes; v-once on the host div tells Vue not to
114+ // touch the injected content.
115+ try {
116+ const res = await fetch (withBase (' /zarr-hero.svg' ))
117+ if (res .ok ) {
118+ host .innerHTML = await res .text ()
119+ }
120+ } catch {
121+ // Network failure — leave the host empty. The wrapper's aria-label
122+ // still describes what should be here for screen readers.
123+ return
124+ }
94125
95126 const set = (chunk : Element ) => {
96- if (! visualRankBuilt ) buildVisualRank (root )
127+ if (! visualRankBuilt ) buildVisualRank (host )
97128 if (currentChunk && currentChunk !== chunk ) {
98129 currentChunk .classList .remove (' is-hovered' )
99130 }
@@ -115,29 +146,29 @@ onMounted(() => {
115146 const onOver = (e : PointerEvent ) => {
116147 const target = e .target as Element | null
117148 const chunk = target ?.closest ?.(' .c' )
118- if (chunk && root .contains (chunk )) set (chunk )
149+ if (chunk && host .contains (chunk )) set (chunk )
119150 }
120151 const onOut = (e : PointerEvent ) => {
121- // Only clear when leaving the entire wrapper (not when sliding
122- // between chunks).
123152 const related = e .relatedTarget as Element | null
124153 if (related && root .contains (related )) return
125154 clear ()
126155 }
127156 const onClick = (e : Event ) => {
128157 const target = e .target as Element | null
129158 const chunk = target ?.closest ?.(' .c' )
130- if (chunk && root .contains (chunk )) set (chunk )
159+ if (chunk && host .contains (chunk )) set (chunk )
131160 else clear ()
132161 }
133162
134- root .addEventListener (' pointerover' , onOver )
135- root .addEventListener (' pointerout' , onOut )
136- root .addEventListener (' click' , onClick )
163+ // Listeners attach on the SVG host (where the chunks now live), not
164+ // the wrapper, so the popup itself doesn't intercept hover events.
165+ host .addEventListener (' pointerover' , onOver )
166+ host .addEventListener (' pointerout' , onOut )
167+ host .addEventListener (' click' , onClick )
137168 cleanup = () => {
138- root .removeEventListener (' pointerover' , onOver )
139- root .removeEventListener (' pointerout' , onOut )
140- root .removeEventListener (' click' , onClick )
169+ host .removeEventListener (' pointerover' , onOver )
170+ host .removeEventListener (' pointerout' , onOut )
171+ host .removeEventListener (' click' , onClick )
141172 }
142173})
143174
@@ -149,8 +180,9 @@ onBeforeUnmount(() => cleanup?.())
149180 ref =" wrap"
150181 class =" zarr-hero"
151182 :class =" { 'has-hover': hovered }"
183+ aria-label =" Zarr animated chunked cube"
152184 >
153- <div class =" zarr-hero__svg" v-html = " svg " />
185+ <div ref = " svgHost " class =" zarr-hero__svg" v-once />
154186 <Transition name="callout">
155187 <div v-if =" hovered" class =" zarr-callout" :style =" calloutStyle" >
156188 <div class =" zarr-callout__addr" >chunk[{{ coords }}]</div >
@@ -161,7 +193,7 @@ onBeforeUnmount(() => cleanup?.())
161193 </div >
162194 <div >
163195 <dt >codec</dt >
164- <dd >zstd · < strong >{{ stats.ratio }}×</ strong > compressed </dd >
196+ <dd >zstd</dd >
165197 </div >
166198 </dl >
167199 </div >
@@ -193,22 +225,22 @@ onBeforeUnmount(() => cleanup?.())
193225 --zarr-glow : rgba (232 , 155 , 184 , 0.7 );
194226}
195227
228+ .zarr-hero__svg {
229+ /* Reserve space so layout doesn't shift while the SVG fetches in. */
230+ aspect-ratio : 560 / 480 ;
231+ width : 100% ;
232+ }
196233.zarr-hero :deep(svg ) {
197234 display : block ;
198235 width : 100% ;
199236 height : auto ;
200237}
201238
202- /* Each chunk is interactive — hover/tap to reveal its address and a
203- data preview in the floating callout. */
204239.zarr-hero :deep(.c ) {
205240 cursor : pointer ;
206241 transition : opacity 200ms ease , filter 200ms ease ;
207242}
208243
209- /* When ANY chunk is hovered/selected, dim the rest so the focused
210- chunk reads clearly. !important is needed because each chunk has
211- `animation-fill-mode: forwards` holding its post-reveal opacity. */
212244.zarr-hero.has-hover :deep(.c :not (.is-hovered )) {
213245 opacity : 0.32 !important ;
214246}
@@ -218,7 +250,6 @@ onBeforeUnmount(() => cleanup?.())
218250 drop-shadow (0 0 14px var (--zarr-glow ));
219251}
220252
221- /* Floating callout */
222253.zarr-callout {
223254 position : absolute ;
224255 z-index : 10 ;
@@ -263,10 +294,6 @@ onBeforeUnmount(() => cleanup?.())
263294 color : var (--vp-c-text-2 );
264295 line-height : 1.4 ;
265296}
266- .zarr-callout__meta dd strong {
267- color : var (--vp-c-text-1 );
268- font-weight : 600 ;
269- }
270297
271298.callout-enter-active ,
272299.callout-leave-active {
@@ -278,8 +305,6 @@ onBeforeUnmount(() => cleanup?.())
278305 transform : scale (0.95 );
279306}
280307
281- /* Reduced motion: disable the reveal stagger and the callout's
282- scale-in. Hover/tap behaviour stays. */
283308@media (prefers-reduced-motion: reduce) {
284309 .zarr-hero :deep(.c ) {
285310 animation : none !important ;
0 commit comments