diff --git a/app/src/components/Explorer.tsx b/app/src/components/Explorer.tsx index fedc6c9a..292eb280 100644 --- a/app/src/components/Explorer.tsx +++ b/app/src/components/Explorer.tsx @@ -1,5 +1,5 @@ import type { ComponentChildren } from 'preact'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks'; import { prerequisitesOf, shortestPath, type Graph, type GraphNode } from '../lib/graph-model'; import { loadLearner, type Learner } from '../lib/learner'; import { @@ -57,6 +57,18 @@ type Mode = '2d' | '3d'; type ColourMode = 'cluster' | 'knowledge'; /** The control bar's popovers; only one is open at a time ('sheet' = phones' Controls). */ type Pop = 'links' | 'route' | 'sheet'; +/** + * How much of the control bar fits (A93b): named domain pills and an open search field + * when there is room, dot chips next, then short labels and a search icon, and — when + * even that would wrap — the phones' "Controls" sheet. Measured, never a second row. + */ +type BarSize = 'full' | 'medium' | 'compact' | 'sheet'; +const SMALLER: Record = { + full: 'medium', + medium: 'compact', + compact: 'sheet', + sheet: 'sheet', +}; /** A term under a resting pointer, in map pixels (the hover card's anchor). */ type Point = { id: string; x: number; y: number }; @@ -142,6 +154,43 @@ export default function Explorer(props: Props) { return () => mq.removeEventListener('change', on); }, []); const bar = useRef(null); + /** The space the bar is centred in (between the legend and the "i"). */ + const slot = useRef(null); + const [barSize, setBarSize] = useState('full'); + // Start from the fullest bar whenever the space (or what the bar holds) changes... + useEffect(() => { + const el = slot.current; + if (!el) return; + let width = el.clientWidth; + const ro = new ResizeObserver(() => { + if (el.clientWidth === width) return; + width = el.clientWidth; + setBarSize('full'); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + /** Phones, or a desktop bar too tight for one row: the "Controls" sheet. */ + const sheet = narrow || barSize === 'sheet'; + // ...and step down, before paint, while its one row overflows. + useLayoutEffect(() => { + const b = bar.current; + // An opened search field in a compact bar may overflow a little: never a sheet for it. + if (barSize === 'sheet' || (barSize === 'compact' && findOpen)) return; + // In-flow widths only: an open popover or result list must not shrink the bar. + const room = slot.current?.clientWidth ?? 0; + const need = (el: HTMLElement) => { + const kids = [...el.children] as HTMLElement[]; + const gap = parseFloat(getComputedStyle(el).columnGap) || 0; + const pad = el.offsetWidth - el.clientWidth + 12; // border + px-1.5 padding + return ( + kids.reduce((w, k) => w + k.getBoundingClientRect().width, 0) + + gap * (kids.length - 1) + + pad + ); + }; + if (b && !narrow && room && need(b) > room + 1) setBarSize(SMALLER[barSize]); + }); // A popover closes on Esc (focus back on its button) or a press outside the bar. The // term panel's Esc handler stands down while a [data-map-popover] is open. useEffect(() => { @@ -356,6 +405,7 @@ export default function Explorer(props: Props) { onHover: (id) => prefetchTerm(props.panel.apiBase, id), onPoint, theme, + domainLabels: props.domainLabels, }), ) .then((m) => { @@ -368,6 +418,7 @@ export default function Explorer(props: Props) { }, [mode, graph]); useEffect(() => () => map3d?.destroy(), [map3d]); useEffect(() => map3d?.spin(spin), [map3d, spin]); + useEffect(() => setBarSize('full'), [mode, legendOpen, lang]); useEffect(() => map3d?.retheme(theme), [map3d, theme]); useEffect(() => { onPoint(null); @@ -550,10 +601,10 @@ export default function Explorer(props: Props) { {ui.autoRotate} ); - /** Domain toggles: labelled pills in the phone sheet, dot-only chips in the bar. */ - const domainPills = (compact: boolean) => ( + /** Domain toggles: labelled pills (the sheet, a full bar) or dot-only chips (a tighter bar). */ + const domainPills = (compact: boolean, wrap = true) => (
); - // In the bar, "Find a term" is a search icon that opens the field (keeps the bar one row). + // In a compact bar, "Find a term" is a search icon that opens the field (keeps one row). const search = - !narrow && !findOpen ? ( + barSize === 'compact' && !findOpen ? (