@humanforest/nuxt-layer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +67 -0
- package/README.md +208 -0
- package/app.config.ts +10 -0
- package/assets/icons/forest/bike-asterisk.svg +21 -0
- package/assets/icons/forest/bike-down.svg +19 -0
- package/assets/icons/forest/bike-off.svg +20 -0
- package/assets/icons/forest/bike-up.svg +19 -0
- package/assets/icons/forest/bike-x.svg +19 -0
- package/assets/icons/forest/bike.svg +18 -0
- package/assets/icons/forest/mark-fill.svg +6 -0
- package/assets/icons/forest/mark.svg +6 -0
- package/assets/icons/forest/parking.svg +3 -0
- package/assets/icons/forest/star-fill.svg +4 -0
- package/assets/icons/forest/star.svg +4 -0
- package/assets/icons/forest/traffic-light-caution.svg +8 -0
- package/assets/icons/forest/traffic-light-go.svg +8 -0
- package/assets/icons/forest/traffic-light-lit.svg +8 -0
- package/assets/icons/forest/traffic-light-stop.svg +8 -0
- package/assets/icons/forest/traffic-light.svg +8 -0
- package/nuxt.config.ts +81 -0
- package/package.json +35 -0
- package/packages/charts/src/FBoroughShape.vue +52 -0
- package/packages/charts/src/FCalendarGrid.vue +332 -0
- package/packages/charts/src/FCellLegend.vue +54 -0
- package/packages/charts/src/FChartFrame.vue +201 -0
- package/packages/charts/src/FChartFrameSingle.vue +91 -0
- package/packages/charts/src/FChartLegend.vue +26 -0
- package/packages/charts/src/FDistributionBar.vue +224 -0
- package/packages/charts/src/FDottedMap.vue +335 -0
- package/packages/charts/src/FLondonMap.vue +399 -0
- package/packages/charts/src/FSparkline.vue +198 -0
- package/packages/charts/src/FStatusTrack.vue +151 -0
- package/packages/charts/src/cellSize.ts +46 -0
- package/packages/charts/src/distribution.ts +99 -0
- package/packages/charts/src/engine.ts +15 -0
- package/packages/charts/src/forestTooltip.ts +110 -0
- package/packages/charts/src/fromCategories.ts +43 -0
- package/packages/charts/src/index.ts +87 -0
- package/packages/charts/src/londonAreas.ts +75 -0
- package/packages/charts/src/londonGrid.ts +67 -0
- package/packages/charts/src/motionDuration.ts +49 -0
- package/packages/charts/src/presets.ts +143 -0
- package/packages/charts/src/resolveVar.ts +21 -0
- package/packages/charts/src/snapTooltip.ts +154 -0
- package/packages/charts/src/tooltipStandIn.ts +58 -0
- package/packages/charts/src/useCellScale.ts +155 -0
- package/packages/charts/src/useChartPalette.ts +61 -0
- package/packages/charts/src/useChartRepaintKey.ts +22 -0
- package/packages/charts/src/useThemeVersion.ts +17 -0
- package/packages/frames/src/FBrowserFrame.vue +53 -0
- package/packages/frames/src/FDeviceFrame.vue +242 -0
- package/packages/frames/src/FFitFrame.vue +83 -0
- package/packages/frames/src/art/iphone-11.svg +124 -0
- package/packages/frames/src/art/iphone-12.svg +601 -0
- package/packages/frames/src/art/iphone-13-pro.svg +820 -0
- package/packages/frames/src/art/iphone-13.svg +849 -0
- package/packages/frames/src/art/iphone-14.svg +681 -0
- package/packages/frames/src/art/iphone-15-pro.svg +633 -0
- package/packages/frames/src/art/iphone-15.svg +713 -0
- package/packages/frames/src/art/iphone-16-plus.svg +685 -0
- package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
- package/packages/frames/src/art/iphone-16-pro.svg +631 -0
- package/packages/frames/src/art/iphone-16.svg +713 -0
- package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
- package/packages/frames/src/art/iphone-17.svg +305 -0
- package/packages/frames/src/art/iphone-air.svg +389 -0
- package/packages/frames/src/art/iphone-se-3.svg +473 -0
- package/packages/frames/src/art/iphone-x.svg +652 -0
- package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-10-pro.svg +32 -0
- package/packages/frames/src/art/pixel-10.svg +32 -0
- package/packages/frames/src/art/pixel-8-pro.svg +208 -0
- package/packages/frames/src/art/pixel-8.svg +208 -0
- package/packages/frames/src/art/pixel-8a.svg +8 -0
- package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-9-pro.svg +34 -0
- package/packages/frames/src/art/pixel-9a.svg +25 -0
- package/packages/frames/src/art.ts +29 -0
- package/packages/frames/src/devices.ts +337 -0
- package/packages/frames/src/index.ts +15 -0
- package/packages/tokens/geo/london-boroughs.json +1 -0
- package/packages/tokens/geo/london-dots-coarse.json +1 -0
- package/packages/tokens/geo/london-dots-fine.json +1 -0
- package/packages/tokens/geo/london-dots-medium.json +1 -0
- package/packages/tokens/geo/london-dots-ultra.json +1 -0
- package/packages/tokens/geo/london-wards.json +1 -0
- package/packages/tokens/glyphs/glyphs.json +1 -0
- package/packages/tokens/logos/f-rot.svg +4 -0
- package/packages/tokens/logos/f.svg +4 -0
- package/packages/tokens/logos/favicon-c2w.svg +9 -0
- package/packages/tokens/logos/favicon-console.svg +9 -0
- package/packages/tokens/logos/favicon-ds.svg +9 -0
- package/packages/tokens/logos/favicon-fleet.svg +9 -0
- package/packages/tokens/logos/favicon-flex.svg +9 -0
- package/packages/tokens/logos/favicon-plus.svg +9 -0
- package/packages/tokens/logos/favicon-radar.svg +9 -0
- package/packages/tokens/logos/favicon-tower.svg +9 -0
- package/packages/tokens/logos/lockup-horizontal.svg +17 -0
- package/packages/tokens/logos/lockup-vertical.svg +17 -0
- package/packages/tokens/logos/mark-mono.svg +4 -0
- package/packages/tokens/logos/mark.svg +7 -0
- package/packages/tokens/logos/wordmark.svg +9 -0
- package/packages/tokens/src/colourEngine.ts +604 -0
- package/packages/tokens/src/glyphs.ts +127 -0
- package/packages/tokens/src/logo.ts +50 -0
- package/packages/tokens/src/subbrands.ts +44 -0
- package/packages/tokens/src/sublogo.ts +80 -0
- package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
- package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
- package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
- package/packages/ui/icons/forest/map/manifest.json +373 -0
- package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
- package/packages/ui/icons/forest/map/place-manifest.json +74 -0
- package/packages/ui/src/address/address.ts +125 -0
- package/packages/ui/src/address/autofill.ts +78 -0
- package/packages/ui/src/address/geocode.ts +55 -0
- package/packages/ui/src/address/index.ts +31 -0
- package/packages/ui/src/address/validate.ts +91 -0
- package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
- package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
- package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
- package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
- package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
- package/packages/ui/src/canvas/canvasSurface.ts +39 -0
- package/packages/ui/src/canvas/index.ts +17 -0
- package/packages/ui/src/contexts/ForestContext.vue +53 -0
- package/packages/ui/src/contexts/contexts.ts +153 -0
- package/packages/ui/src/contexts/index.ts +23 -0
- package/packages/ui/src/contexts/marketing-theme.ts +214 -0
- package/packages/ui/src/contexts/mobile-theme.ts +402 -0
- package/packages/ui/src/filter/FFilterBar.vue +72 -0
- package/packages/ui/src/filter/FFilterChip.vue +362 -0
- package/packages/ui/src/filter/FFilterClauses.vue +104 -0
- package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
- package/packages/ui/src/filter/filter.theme.ts +27 -0
- package/packages/ui/src/filter/filterClause.ts +265 -0
- package/packages/ui/src/filter/filterPredicate.ts +134 -0
- package/packages/ui/src/filter/filterSchema.ts +82 -0
- package/packages/ui/src/filter/index.ts +36 -0
- package/packages/ui/src/forest-preset.ts +68 -0
- package/packages/ui/src/icons.ts +79 -0
- package/packages/ui/src/index.ts +17 -0
- package/packages/ui/src/kpi/FKpi.vue +253 -0
- package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
- package/packages/ui/src/kpi/index.ts +20 -0
- package/packages/ui/src/kpi/kpi.theme.ts +168 -0
- package/packages/ui/src/kpi/kpiDelta.ts +82 -0
- package/packages/ui/src/kpi/kpiFormat.ts +72 -0
- package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
- package/packages/ui/src/logo/FLogo.vue +52 -0
- package/packages/ui/src/logo/FSubLogo.vue +214 -0
- package/packages/ui/src/logo/index.ts +8 -0
- package/packages/ui/src/map/FMap.vue +355 -0
- package/packages/ui/src/map/FMapCompass.vue +154 -0
- package/packages/ui/src/map/FMapCoordinates.vue +134 -0
- package/packages/ui/src/map/FMapDrawTools.vue +116 -0
- package/packages/ui/src/map/FMapFullscreen.vue +92 -0
- package/packages/ui/src/map/FMapGeolocate.vue +208 -0
- package/packages/ui/src/map/FMapLayers.vue +75 -0
- package/packages/ui/src/map/FMapLegend.vue +45 -0
- package/packages/ui/src/map/FMapPopup.vue +129 -0
- package/packages/ui/src/map/FMapRamp.vue +69 -0
- package/packages/ui/src/map/FMapRegion.vue +77 -0
- package/packages/ui/src/map/FMapScale.vue +107 -0
- package/packages/ui/src/map/FMapSearch.vue +362 -0
- package/packages/ui/src/map/FMapTilt.vue +87 -0
- package/packages/ui/src/map/FMapTools.vue +61 -0
- package/packages/ui/src/map/FMapZoom.vue +81 -0
- package/packages/ui/src/map/FMarker.vue +218 -0
- package/packages/ui/src/map/FTrackScrubber.vue +94 -0
- package/packages/ui/src/map/FVehicleLegend.vue +72 -0
- package/packages/ui/src/map/FVehicleMark.vue +166 -0
- package/packages/ui/src/map/bin.ts +196 -0
- package/packages/ui/src/map/coordinates.ts +83 -0
- package/packages/ui/src/map/density.ts +401 -0
- package/packages/ui/src/map/draw.ts +356 -0
- package/packages/ui/src/map/ease.ts +67 -0
- package/packages/ui/src/map/fullscreen.ts +59 -0
- package/packages/ui/src/map/heat.ts +160 -0
- package/packages/ui/src/map/index.ts +351 -0
- package/packages/ui/src/map/liveMarkers.ts +60 -0
- package/packages/ui/src/map/map.css +116 -0
- package/packages/ui/src/map/mapColor.ts +31 -0
- package/packages/ui/src/map/mapContext.ts +12 -0
- package/packages/ui/src/map/mapLayers.ts +55 -0
- package/packages/ui/src/map/mapLegend.ts +70 -0
- package/packages/ui/src/map/mapSearch.theme.ts +79 -0
- package/packages/ui/src/map/mapSearch.ts +279 -0
- package/packages/ui/src/map/mapUrl.ts +117 -0
- package/packages/ui/src/map/markerAsset.ts +3 -0
- package/packages/ui/src/map/padding.ts +39 -0
- package/packages/ui/src/map/place.ts +192 -0
- package/packages/ui/src/map/ramp.ts +95 -0
- package/packages/ui/src/map/regionSelect.ts +163 -0
- package/packages/ui/src/map/route.ts +167 -0
- package/packages/ui/src/map/scale.ts +329 -0
- package/packages/ui/src/map/scaleBar.ts +67 -0
- package/packages/ui/src/map/selectionModel.ts +50 -0
- package/packages/ui/src/map/sources/coordinates.ts +58 -0
- package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
- package/packages/ui/src/map/style.ts +14 -0
- package/packages/ui/src/map/track.ts +279 -0
- package/packages/ui/src/map/useFeatureSelect.ts +230 -0
- package/packages/ui/src/map/useLiveMarkers.ts +84 -0
- package/packages/ui/src/map/useMap.ts +150 -0
- package/packages/ui/src/map/useMapDraw.ts +776 -0
- package/packages/ui/src/map/useMapOverlay.ts +421 -0
- package/packages/ui/src/map/useMapTableSelection.ts +101 -0
- package/packages/ui/src/map/useMapUrlState.ts +155 -0
- package/packages/ui/src/map/useMarkerSource.ts +163 -0
- package/packages/ui/src/map/useRegionSelect.ts +309 -0
- package/packages/ui/src/map/useTokenMode.ts +61 -0
- package/packages/ui/src/map/useTrackPlayback.ts +131 -0
- package/packages/ui/src/map/useVehicleFilter.ts +51 -0
- package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
- package/packages/ui/src/map/vehicle.ts +807 -0
- package/packages/ui/src/map/vehicleFilter.ts +284 -0
- package/packages/ui/src/map/vehicleLegend.ts +155 -0
- package/packages/ui/src/map/visibleFeatures.ts +192 -0
- package/packages/ui/src/map/zone.ts +291 -0
- package/packages/ui/src/shell/FAppNavbar.vue +68 -0
- package/packages/ui/src/shell/FAppShell.vue +163 -0
- package/packages/ui/src/shell/FAppSidebar.vue +215 -0
- package/packages/ui/src/shell/FPageToolbar.vue +126 -0
- package/packages/ui/src/shell/FScrollShadow.vue +43 -0
- package/packages/ui/src/shell/FSidePanel.vue +258 -0
- package/packages/ui/src/shell/FToolbarActions.vue +32 -0
- package/packages/ui/src/shell/index.ts +33 -0
- package/packages/ui/src/shell/nav.ts +37 -0
- package/packages/ui/src/shell/pageToolbar.ts +69 -0
- package/packages/ui/src/shell/shell.ts +74 -0
- package/packages/ui/src/shell/sidePanel.ts +115 -0
- package/packages/ui/src/themes/accordion.ts +36 -0
- package/packages/ui/src/themes/alert.ts +16 -0
- package/packages/ui/src/themes/auth-form.ts +19 -0
- package/packages/ui/src/themes/avatar-group.ts +9 -0
- package/packages/ui/src/themes/avatar.ts +18 -0
- package/packages/ui/src/themes/badge.ts +51 -0
- package/packages/ui/src/themes/banner.ts +21 -0
- package/packages/ui/src/themes/breadcrumb.ts +10 -0
- package/packages/ui/src/themes/button.mechanics.ts +191 -0
- package/packages/ui/src/themes/button.ts +136 -0
- package/packages/ui/src/themes/calendar.ts +29 -0
- package/packages/ui/src/themes/card.ts +67 -0
- package/packages/ui/src/themes/carousel.ts +41 -0
- package/packages/ui/src/themes/checkbox-group.ts +20 -0
- package/packages/ui/src/themes/checkbox.ts +47 -0
- package/packages/ui/src/themes/chip.ts +50 -0
- package/packages/ui/src/themes/collapsible.ts +26 -0
- package/packages/ui/src/themes/color-picker.ts +13 -0
- package/packages/ui/src/themes/command-palette.ts +37 -0
- package/packages/ui/src/themes/container.ts +14 -0
- package/packages/ui/src/themes/context-menu.ts +38 -0
- package/packages/ui/src/themes/dashboard-group.ts +11 -0
- package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
- package/packages/ui/src/themes/dashboard-panel.ts +18 -0
- package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
- package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
- package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
- package/packages/ui/src/themes/drawer.ts +45 -0
- package/packages/ui/src/themes/dropdown-menu.ts +38 -0
- package/packages/ui/src/themes/empty.ts +43 -0
- package/packages/ui/src/themes/field-group.ts +52 -0
- package/packages/ui/src/themes/file-upload.ts +38 -0
- package/packages/ui/src/themes/footer-columns.ts +13 -0
- package/packages/ui/src/themes/footer.ts +9 -0
- package/packages/ui/src/themes/form-field.ts +25 -0
- package/packages/ui/src/themes/form.ts +22 -0
- package/packages/ui/src/themes/header.ts +12 -0
- package/packages/ui/src/themes/icon.ts +61 -0
- package/packages/ui/src/themes/index.ts +99 -0
- package/packages/ui/src/themes/input-date.ts +73 -0
- package/packages/ui/src/themes/input-menu.ts +66 -0
- package/packages/ui/src/themes/input-number.ts +58 -0
- package/packages/ui/src/themes/input-rating.ts +25 -0
- package/packages/ui/src/themes/input-tags.ts +63 -0
- package/packages/ui/src/themes/input-time.ts +70 -0
- package/packages/ui/src/themes/input.ts +148 -0
- package/packages/ui/src/themes/kbd.ts +27 -0
- package/packages/ui/src/themes/link.ts +27 -0
- package/packages/ui/src/themes/listbox.ts +48 -0
- package/packages/ui/src/themes/main.ts +6 -0
- package/packages/ui/src/themes/marquee.ts +45 -0
- package/packages/ui/src/themes/modal.ts +46 -0
- package/packages/ui/src/themes/navigation-menu.ts +80 -0
- package/packages/ui/src/themes/page-anchors.ts +7 -0
- package/packages/ui/src/themes/page-aside.ts +7 -0
- package/packages/ui/src/themes/page-card.ts +22 -0
- package/packages/ui/src/themes/page-cta.ts +18 -0
- package/packages/ui/src/themes/page-feature.ts +13 -0
- package/packages/ui/src/themes/page-header.ts +16 -0
- package/packages/ui/src/themes/page-hero.ts +15 -0
- package/packages/ui/src/themes/page-links.ts +12 -0
- package/packages/ui/src/themes/page-section.ts +14 -0
- package/packages/ui/src/themes/page.ts +7 -0
- package/packages/ui/src/themes/pagination.ts +17 -0
- package/packages/ui/src/themes/pin-input.ts +73 -0
- package/packages/ui/src/themes/popover.ts +30 -0
- package/packages/ui/src/themes/progress.ts +29 -0
- package/packages/ui/src/themes/prose.ts +45 -0
- package/packages/ui/src/themes/radio-group.ts +39 -0
- package/packages/ui/src/themes/scroll-area.ts +23 -0
- package/packages/ui/src/themes/select-menu.ts +71 -0
- package/packages/ui/src/themes/select.ts +72 -0
- package/packages/ui/src/themes/separator.ts +28 -0
- package/packages/ui/src/themes/sidebar.ts +127 -0
- package/packages/ui/src/themes/skeleton.ts +15 -0
- package/packages/ui/src/themes/slideover.ts +87 -0
- package/packages/ui/src/themes/slider.ts +48 -0
- package/packages/ui/src/themes/stepper.ts +41 -0
- package/packages/ui/src/themes/switch.ts +63 -0
- package/packages/ui/src/themes/table.ts +34 -0
- package/packages/ui/src/themes/tabs.ts +74 -0
- package/packages/ui/src/themes/textarea.ts +77 -0
- package/packages/ui/src/themes/timeline.ts +25 -0
- package/packages/ui/src/themes/toast.ts +29 -0
- package/packages/ui/src/themes/toaster.ts +66 -0
- package/packages/ui/src/themes/tooltip.ts +31 -0
- package/packages/ui/src/themes/tree.ts +41 -0
- package/packages/ui/src/themes/user.ts +28 -0
- package/styles.cdn.gen.css +2047 -0
- package/theme.gen.json +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Geometry data seam, the same shape as engine.ts: the generated grids are reached across the
|
|
2
|
+
// workspace boundary in exactly one place.
|
|
3
|
+
//
|
|
4
|
+
// The grids are built by scripts/londonDots.ts and are not editable by hand — change the sources in
|
|
5
|
+
// scripts/data/ and rebuild.
|
|
6
|
+
import fine from '@humanforest/tokens/geo/london-dots-fine.json';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How many dots the map is sampled into, which is not the same question as how large it is drawn.
|
|
10
|
+
* `size` is pixels; this is resolution. A coarse grid at lg is big dots read from across a room; a
|
|
11
|
+
* fine grid at sm is a texture.
|
|
12
|
+
*/
|
|
13
|
+
export type DotDensity = 'coarse' | 'medium' | 'fine' | 'ultra';
|
|
14
|
+
|
|
15
|
+
export interface LondonGrid {
|
|
16
|
+
/** [lng0, lat0, lng1, lat1]. */
|
|
17
|
+
bbox: [number, number, number, number];
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
/** Land dots as [column, row]. Everything outside the boundary is simply absent. */
|
|
21
|
+
dots: [number, number][];
|
|
22
|
+
/** The Thames, same indexing. Painted rather than left as a hole, and never carries data. */
|
|
23
|
+
river: [number, number][];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The Thames. A flat token, not a ramp stop — the river is a label on the map, not a reading. */
|
|
27
|
+
export const RIVER_COLOR = 'var(--dataviz-categorical-vivid-sky)';
|
|
28
|
+
|
|
29
|
+
/** The default resolution, bundled so the common case needs no request. */
|
|
30
|
+
export const LONDON_GRID = fine as unknown as LondonGrid;
|
|
31
|
+
|
|
32
|
+
// The other three are fetched on use. Four grids is four times the bytes and a consumer draws at
|
|
33
|
+
// one resolution, so bundling them all would charge everyone for the three they do not draw.
|
|
34
|
+
const cache = new Map<DotDensity, Promise<LondonGrid>>([['fine', Promise.resolve(LONDON_GRID)]]);
|
|
35
|
+
|
|
36
|
+
export const loadLondonGrid = (density: DotDensity): Promise<LondonGrid> => {
|
|
37
|
+
const hit = cache.get(density);
|
|
38
|
+
if (hit) return hit;
|
|
39
|
+
// Spelt out per case rather than built from a template: a bundler can only pre-resolve a dynamic
|
|
40
|
+
// import whose specifier it can read statically.
|
|
41
|
+
const load =
|
|
42
|
+
density === 'coarse'
|
|
43
|
+
? import('@humanforest/tokens/geo/london-dots-coarse.json')
|
|
44
|
+
: density === 'medium'
|
|
45
|
+
? import('@humanforest/tokens/geo/london-dots-medium.json')
|
|
46
|
+
: import('@humanforest/tokens/geo/london-dots-ultra.json');
|
|
47
|
+
const p = load.then((m) => ((m as { default?: unknown }).default ?? m) as unknown as LondonGrid);
|
|
48
|
+
cache.set(density, p);
|
|
49
|
+
return p;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A position to its dot, or null when it falls outside the grid's box.
|
|
54
|
+
*
|
|
55
|
+
* The inverse of the build step's projection — equirectangular, which over London tracks Web
|
|
56
|
+
* Mercator to well under one dot. Returning a cell that holds no land is intentional: only the
|
|
57
|
+
* caller of this can tell "outside London" from "in the river", and both are dropped alike.
|
|
58
|
+
*/
|
|
59
|
+
export const dotAt = (grid: LondonGrid, lat: number, lng: number): [number, number] | null => {
|
|
60
|
+
const [lng0, lat0, lng1, lat1] = grid.bbox;
|
|
61
|
+
const col = Math.floor(((lng - lng0) / (lng1 - lng0)) * grid.width);
|
|
62
|
+
const row = Math.floor(((lat1 - lat) / (lat1 - lat0)) * grid.height);
|
|
63
|
+
if (col < 0 || row < 0 || col >= grid.width || row >= grid.height) return null;
|
|
64
|
+
return [col, row];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const dotKey = (col: number, row: number) => `${col},${row}`;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Resolves a Forest --motion-* duration token to the millisecond number Unovis' `duration`
|
|
2
|
+
// config takes. Charts animate on the system's motion tokens — never Unovis' built-in 600 ms,
|
|
3
|
+
// never a hardcoded number — and the value is read off the live computed style, so a host that
|
|
4
|
+
// re-themes the tokens changes chart timing without touching this file.
|
|
5
|
+
|
|
6
|
+
export type MotionToken = 'instant' | 'fast' | 'base' | 'slow';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The ladder's documented values, for the paths with no DOM to read (SSR, node tests).
|
|
10
|
+
*
|
|
11
|
+
* These are a COPY, and deliberately so — there is no computed style to resolve under SSR, and this
|
|
12
|
+
* file cannot import the generated packages/tokens/motion/motion.json either: packages/charts/src is
|
|
13
|
+
* mirrored into the Nuxt layer by scripts/layer.ts, whose self-containment guard rejects a relative
|
|
14
|
+
* climb into a tree the layer does not carry.
|
|
15
|
+
*
|
|
16
|
+
* So the copy is PINNED instead of trusted: packages/ui/test/motion-tokens.static.test.ts asserts this
|
|
17
|
+
* map against motion.json on every CI run, and fails if either side moves without the other.
|
|
18
|
+
*/
|
|
19
|
+
const TOKEN_FALLBACK_MS: Record<MotionToken, number> = {
|
|
20
|
+
instant: 80,
|
|
21
|
+
fast: 140,
|
|
22
|
+
base: 180,
|
|
23
|
+
slow: 240,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Millisecond value of a `--motion-*` token, for Unovis `duration` configs.
|
|
28
|
+
*
|
|
29
|
+
* The default is `slow` (240 ms): a chart entrance animates the whole plot area — the largest
|
|
30
|
+
* travel of any Forest animation — which is exactly the "larger travel" bucket --motion-slow
|
|
31
|
+
* names, and the slowest duration the system allows.
|
|
32
|
+
*
|
|
33
|
+
* `prefers-reduced-motion: reduce` resolves to 0, unconditionally.
|
|
34
|
+
*/
|
|
35
|
+
export function motionDuration(token: MotionToken = 'slow'): number {
|
|
36
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
37
|
+
return TOKEN_FALLBACK_MS[token];
|
|
38
|
+
}
|
|
39
|
+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return 0;
|
|
40
|
+
const raw = getComputedStyle(document.documentElement)
|
|
41
|
+
.getPropertyValue(`--motion-${token}`)
|
|
42
|
+
.trim();
|
|
43
|
+
const ms = raw.endsWith('ms')
|
|
44
|
+
? Number.parseFloat(raw)
|
|
45
|
+
: raw.endsWith('s')
|
|
46
|
+
? Number.parseFloat(raw) * 1000
|
|
47
|
+
: Number.parseFloat(raw);
|
|
48
|
+
return Number.isFinite(ms) ? ms : TOKEN_FALLBACK_MS[token];
|
|
49
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Shared per-site Unovis config. Each preset carries only what direct call sites repeat verbatim —
|
|
2
|
+
// per-chart concerns (tick values, formatters, counts, domains) stay at the call site. Spread with
|
|
3
|
+
// v-bind and override next to it: `<VisAxis v-bind="axisX" :tick-values="…" />`.
|
|
4
|
+
import { Position } from '@unovis/ts';
|
|
5
|
+
import type {
|
|
6
|
+
AxisConfigInterface,
|
|
7
|
+
BulletLegendConfigInterface,
|
|
8
|
+
TooltipConfigInterface,
|
|
9
|
+
} from '@unovis/ts';
|
|
10
|
+
|
|
11
|
+
/** X axis: no grid lines. No duration here — animation timing is container-owned: the frame
|
|
12
|
+
* resolves a --motion-* token onto the container and Unovis propagates it to axes; pages that
|
|
13
|
+
* want a static chart pass 0 at the container. */
|
|
14
|
+
export const axisX: Partial<AxisConfigInterface<unknown>> = Object.freeze({
|
|
15
|
+
type: 'x',
|
|
16
|
+
gridLine: false,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Y axis: grid lines stay on (they carry the value scale). Duration container-owned, as above. */
|
|
20
|
+
export const axisY: Partial<AxisConfigInterface<unknown>> = Object.freeze({
|
|
21
|
+
type: 'y',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Legend: currently empty. A bare `<VisBulletLegend :items="…">` already renders the house look —
|
|
25
|
+
* spacing and type come from the adapter's --vis-legend-* custom properties, and no call site
|
|
26
|
+
* repeats any config. The object exists so shared legend config has one home when it appears. */
|
|
27
|
+
export const legendDefaults: Partial<BulletLegendConfigInterface> = Object.freeze({});
|
|
28
|
+
|
|
29
|
+
/** Tooltip: above-right of the anchor, held clear of the pointer.
|
|
30
|
+
*
|
|
31
|
+
* followCursor is off so a tooltip pins to the hovered mark and holds still while the pointer
|
|
32
|
+
* moves inside it — readable, and reachable when `allowHover` is on. Two things bound that.
|
|
33
|
+
* Crosshair force-sets followCursor back to true on every show, so XY charts with a crosshair
|
|
34
|
+
* track the pointer whatever is set here. And pinning anchors to the hovered element's BOUNDING
|
|
35
|
+
* BOX, so it only reads as "next to what I hovered" when the box is the mark: right for a bar,
|
|
36
|
+
* wrong for a donut arc or a treemap group, whose boxes are far larger than the shape under the
|
|
37
|
+
* cursor. Hence FChartFrameSingle, which carries exactly those marks, defaults the other way.
|
|
38
|
+
*
|
|
39
|
+
* The shifts are what keep the pointer off the panel on that tracking path. Unovis adds a fixed
|
|
40
|
+
* 5px margin, which puts the tooltip's corner under the cursor's own hotspot; these open that to a
|
|
41
|
+
* real gap on both axes. They apply only because placement is Left/Right and Top/Bottom — under
|
|
42
|
+
* Position.Auto or Center the library ignores them.
|
|
43
|
+
*
|
|
44
|
+
* Container styling is not set here; it lives on the --vis-tooltip-* custom properties the
|
|
45
|
+
* adapter themes. The `container` (which bounds the tooltip) is set by the frames, not here —
|
|
46
|
+
* it needs `document`, which a module-scope constant must not touch. */
|
|
47
|
+
export const tooltipDefaults: Partial<TooltipConfigInterface> = Object.freeze({
|
|
48
|
+
followCursor: false,
|
|
49
|
+
horizontalPlacement: Position.Right,
|
|
50
|
+
verticalPlacement: Position.Top,
|
|
51
|
+
horizontalShift: 12,
|
|
52
|
+
verticalShift: 12,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** The element the tooltip is positioned and clamped inside. Unovis defaults this to the chart
|
|
56
|
+
* container, and then clamps the panel to it — on a 280px-tall chart a low hover has nowhere to
|
|
57
|
+
* go, so the clamp drags the tooltip back down over the pointer, which is the exact collision the
|
|
58
|
+
* shifts above are meant to prevent. Bounding it by the viewport instead gives it the room to stay
|
|
59
|
+
* clear; the cost is that the panel may overhang the chart's card, which is what a tooltip should
|
|
60
|
+
* do. Returns undefined outside the browser so the frames stay render-safe without a DOM. */
|
|
61
|
+
export function tooltipContainer(): HTMLElement | undefined {
|
|
62
|
+
return typeof document === 'undefined' ? undefined : document.body;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The whole house tooltip config, container included — what a call site mounting its own
|
|
66
|
+
* `<VisTooltip>` should bind. Bars and scatters do exactly that (they carry `triggers` keyed by
|
|
67
|
+
* mark selector rather than riding a crosshair), and binding `tooltipDefaults` alone leaves them
|
|
68
|
+
* clamped inside the chart box, which is the collision described above. Call it in the template:
|
|
69
|
+
* `<VisTooltip v-bind="houseTooltip()" :triggers="…" />`. */
|
|
70
|
+
export function houseTooltip(): Partial<TooltipConfigInterface> {
|
|
71
|
+
return { ...tooltipDefaults, container: tooltipContainer() };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Treemap tile shape. Unovis ships 2px corners and a 2px gap, which reads as a raw partition;
|
|
75
|
+
* the house tile is a rounded slab with room to breathe, matching the card radius family. The
|
|
76
|
+
* gap works with the adapter's tile stroke — padding separates, stroke defines the edge.
|
|
77
|
+
*
|
|
78
|
+
* Typed structurally rather than as `Partial<TreemapConfigInterface<…>>`: v-binding a config
|
|
79
|
+
* object carrying the Datum generic pins it to whatever that object declares, and every accessor
|
|
80
|
+
* bound alongside then fails to match the caller's row type. These keys are plain numbers, so a
|
|
81
|
+
* structural type keeps the component's inference free. */
|
|
82
|
+
export const treemapDefaults: {
|
|
83
|
+
tileBorderRadius: number;
|
|
84
|
+
tilePadding: number;
|
|
85
|
+
tilePaddingTop: number;
|
|
86
|
+
showTileClickAffordance: boolean;
|
|
87
|
+
labelOffsetX: number;
|
|
88
|
+
labelOffsetY: number;
|
|
89
|
+
tileLabelSmallFontSize: number;
|
|
90
|
+
tileLabelMediumFontSize: number;
|
|
91
|
+
tileLabelLargeFontSize: number;
|
|
92
|
+
} = Object.freeze({
|
|
93
|
+
tileBorderRadius: 12,
|
|
94
|
+
tilePadding: 4,
|
|
95
|
+
// The strip a group reserves for its own heading. It has to clear the heading type the adapter
|
|
96
|
+
// sets on internal labels — too small and the zone name is cut off by its own group.
|
|
97
|
+
tilePaddingTop: 26,
|
|
98
|
+
// Unovis draws group and leaf tiles as flat sibling rects with one shared rx, so nesting has no
|
|
99
|
+
// radius of its own. This flag is the only thing that marks leaves in the DOM (it adds a class to
|
|
100
|
+
// tiles with no children), which is what lets the adapter give them the concentric inner radius.
|
|
101
|
+
// Its other effect is a pointer cursor on leaves — correct here, since leaves carry the tooltip.
|
|
102
|
+
showTileClickAffordance: true,
|
|
103
|
+
// Plain inset from the tile edge, independent of the radius: the adapter squares the label clip,
|
|
104
|
+
// so a label is no longer sliced by its own clip corner and these do not have to dodge an arc.
|
|
105
|
+
labelOffsetX: 8,
|
|
106
|
+
labelOffsetY: 6,
|
|
107
|
+
// Leaf label type. Unovis buckets leaves into three sizes by sqrt(value) against the largest
|
|
108
|
+
// leaf, and its own 8/12/22 steps +4 then +10 — so two stations of similar size can land either
|
|
109
|
+
// side of the top threshold and differ by nearly double. These are evenly spaced and top out
|
|
110
|
+
// level with the group heading, which stays distinct on weight rather than size.
|
|
111
|
+
tileLabelSmallFontSize: 12,
|
|
112
|
+
tileLabelMediumFontSize: 14,
|
|
113
|
+
tileLabelLargeFontSize: 16,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** Nested donut ring shape. Unovis ships square corners and no ring gap, which reads as one solid
|
|
117
|
+
* disc cut into wedges; the house rings are separated slabs with the same rounded feel as the
|
|
118
|
+
* treemap tile and the donut arc.
|
|
119
|
+
*
|
|
120
|
+
* cornerRadius is the one number to be careful with. The flat donut clamps it to half the arc
|
|
121
|
+
* width, so an over-large value degrades gracefully there — the nested donut has NO such clamp,
|
|
122
|
+
* so a radius that suits the outer ring will visibly deform a short inner segment. 6 is the
|
|
123
|
+
* largest value that stays clean on the thinnest ring this chart is meant to carry; the flat
|
|
124
|
+
* donut's 4 is the comparable figure at its own 28px arc width.
|
|
125
|
+
*
|
|
126
|
+
* Typed structurally rather than as `Partial<NestedDonutConfigInterface<…>>` for the same reason
|
|
127
|
+
* treemapDefaults is: v-binding a config object carrying the Datum generic pins it, and every
|
|
128
|
+
* accessor bound alongside then fails to match the caller's row type. */
|
|
129
|
+
export const nestedDonutDefaults: {
|
|
130
|
+
cornerRadius: number;
|
|
131
|
+
layerPadding: number;
|
|
132
|
+
showBackground: boolean;
|
|
133
|
+
} = Object.freeze({
|
|
134
|
+
cornerRadius: 6,
|
|
135
|
+
// The ring gap. Segments carry a stroke rather than a real gap, so this works with the adapter's
|
|
136
|
+
// segment stroke the way tilePadding works with the treemap's — padding separates the rings,
|
|
137
|
+
// the stroke defines each segment's edge against its neighbours in the same ring.
|
|
138
|
+
layerPadding: 4,
|
|
139
|
+
// The track behind the segments. Our rings always sum to the whole, so a visible track only ever
|
|
140
|
+
// surfaces in the gaps, where any opaque colour is wrong the moment the chart sits on a surface
|
|
141
|
+
// that is not that colour — the same call the adapter makes for the flat donut's track.
|
|
142
|
+
showBackground: false,
|
|
143
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Resolve a `var(--x)` string to the value it currently computes to, without caching.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: @unovis/ts resolves CSS variables through its own getCSSVariableValue, which
|
|
4
|
+
// memoises each variable per element in a module-level WeakMap and never invalidates it. Anything
|
|
5
|
+
// Unovis decides in JS from a resolved value — the treemap's label ink is the one in this repo —
|
|
6
|
+
// therefore keeps the palette it saw on first paint, and a colour-mode flip cannot change it. A
|
|
7
|
+
// remount does not help: the container is new, the cache is not. Only a page reload clears it.
|
|
8
|
+
//
|
|
9
|
+
// Fills passed as `var()` strings are unaffected, because CSS resolves those continuously. This is
|
|
10
|
+
// only needed where a value has to be correct AT THE MOMENT UNOVIS READS IT — hand it a plain
|
|
11
|
+
// colour and its cache is never consulted.
|
|
12
|
+
//
|
|
13
|
+
// Read fresh on every call: the point is to defeat a cache, so adding one here would restore the
|
|
14
|
+
// bug. Callers should invoke this inside a render that re-runs on `useThemeVersion`.
|
|
15
|
+
|
|
16
|
+
/** The computed value of a `var(--x)` string. Non-var strings pass through untouched. */
|
|
17
|
+
export function resolveVar(value: string, context: Element = document.documentElement): string {
|
|
18
|
+
if (typeof window === 'undefined' || !value.startsWith('var(--')) return value;
|
|
19
|
+
const name = value.slice(4, -1).split(',')[0].trim();
|
|
20
|
+
return getComputedStyle(context).getPropertyValue(name).trim() || value;
|
|
21
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Anchors a frame's tooltip to the datum rather than to the pointer.
|
|
2
|
+
//
|
|
3
|
+
// Unovis offers two anchors and neither is the one we want: the raw pointer (which free-floats,
|
|
4
|
+
// since the panel drifts with every pixel of mouse movement even though the reading it shows only
|
|
5
|
+
// changes datum to datum), or the hovered element's bounding box (which is the mark only when the
|
|
6
|
+
// mark fills its box — true of a bar, false of a donut arc, whose box is most of the ring).
|
|
7
|
+
//
|
|
8
|
+
// So we let Unovis show, hide and fill the panel, and take over only where it lands: a
|
|
9
|
+
// MutationObserver catches each placement and rewrites `left`/`top` against an anchor we compute.
|
|
10
|
+
// Writing from the observer callback lands in the same microtask, before paint, so the corrected
|
|
11
|
+
// position is the only one that ever renders — no flicker, no second frame.
|
|
12
|
+
//
|
|
13
|
+
// Two anchors, matching how the two frames are read:
|
|
14
|
+
// crosshair — the crosshair line's x, panel held at the top of the plot. It steps as the
|
|
15
|
+
// crosshair snaps between rows and holds still in between, which is the whole point.
|
|
16
|
+
// mark — the centroid of the hovered shape, panel just outside it on the side with room.
|
|
17
|
+
// Sampled along the outline rather than taken from the box, so an arc anchors at its
|
|
18
|
+
// own mid-angle instead of at the corner of a box it never touches.
|
|
19
|
+
import { onBeforeUnmount, onMounted, type Ref } from 'vue';
|
|
20
|
+
import { Crosshair } from '@unovis/ts';
|
|
21
|
+
|
|
22
|
+
/** Gap between anchor and panel. Matches the tooltip preset's shifts, so the snapped panel sits
|
|
23
|
+
* the same distance off its anchor as a cursor-following one sits off the pointer. */
|
|
24
|
+
const GAP = 12;
|
|
25
|
+
/** Keep the panel this far inside the viewport when an anchor sits near an edge. */
|
|
26
|
+
const EDGE = 8;
|
|
27
|
+
|
|
28
|
+
let seq = 0;
|
|
29
|
+
/** Per-instance id, written onto the tooltip as a data attribute so a frame can find its own panel.
|
|
30
|
+
* Necessary because the tooltips are children of `body` (see `tooltipContainer`), so a frame
|
|
31
|
+
* cannot simply look inside itself for one. */
|
|
32
|
+
export function nextTipId(): string {
|
|
33
|
+
seq += 1;
|
|
34
|
+
return `forest-tip-${seq}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi);
|
|
38
|
+
|
|
39
|
+
/** Centre of a shape, in viewport coordinates. Paths are sampled along their outline and averaged:
|
|
40
|
+
* for a donut arc that lands at mid-angle, mid-radius — the middle of the visible band — where the
|
|
41
|
+
* box centre would land in the hole, or outside the wedge entirely on a small segment. Anything
|
|
42
|
+
* without a length (rects) falls back to its box, which for those IS the mark. */
|
|
43
|
+
function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
|
|
44
|
+
const path = el as SVGPathElement;
|
|
45
|
+
if (typeof path.getTotalLength === 'function') {
|
|
46
|
+
const total = path.getTotalLength();
|
|
47
|
+
const ctm = el.getScreenCTM();
|
|
48
|
+
if (total > 0 && ctm) {
|
|
49
|
+
const SAMPLES = 24;
|
|
50
|
+
let sx = 0;
|
|
51
|
+
let sy = 0;
|
|
52
|
+
for (let i = 0; i < SAMPLES; i += 1) {
|
|
53
|
+
const p = path.getPointAtLength((total * i) / SAMPLES);
|
|
54
|
+
sx += p.x * ctm.a + p.y * ctm.c + ctm.e;
|
|
55
|
+
sy += p.x * ctm.b + p.y * ctm.d + ctm.f;
|
|
56
|
+
}
|
|
57
|
+
return { x: sx / SAMPLES, y: sy / SAMPLES };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const r = el.getBoundingClientRect();
|
|
61
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SnapMode = 'crosshair' | 'mark';
|
|
65
|
+
|
|
66
|
+
/** Installs the snap on a frame. `enabled` is read live so a frame can hand positioning back to
|
|
67
|
+
* Unovis (the `followCursor` escape hatch) without tearing anything down. */
|
|
68
|
+
export function useTooltipSnap(
|
|
69
|
+
root: Ref<HTMLElement | undefined>,
|
|
70
|
+
tipId: string,
|
|
71
|
+
mode: SnapMode,
|
|
72
|
+
enabled: () => boolean,
|
|
73
|
+
): void {
|
|
74
|
+
// The shape under the pointer, for `mark` mode. Read from the event rather than from Unovis,
|
|
75
|
+
// which keeps its hovered element private.
|
|
76
|
+
let hovered: SVGGraphicsElement | undefined;
|
|
77
|
+
// Guards the observer against our own writes, which would otherwise re-enter it forever.
|
|
78
|
+
let writing = false;
|
|
79
|
+
let observer: MutationObserver | undefined;
|
|
80
|
+
let frame = 0;
|
|
81
|
+
|
|
82
|
+
function onPointerMove(e: Event): void {
|
|
83
|
+
const t = e.target;
|
|
84
|
+
hovered = t instanceof SVGGraphicsElement && t.ownerSVGElement ? t : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function anchorFor(host: HTMLElement): { x: number; y: number } | undefined {
|
|
88
|
+
if (mode === 'crosshair') {
|
|
89
|
+
const line = host.querySelector<SVGGraphicsElement>(`.${Crosshair.selectors.line}`);
|
|
90
|
+
if (!line) return undefined;
|
|
91
|
+
const r = line.getBoundingClientRect();
|
|
92
|
+
// The line spans the plot, so its box gives both the snapped x and the plot's top edge.
|
|
93
|
+
return { x: r.x + r.width / 2, y: r.y };
|
|
94
|
+
}
|
|
95
|
+
if (!hovered || !host.contains(hovered)) return undefined;
|
|
96
|
+
return shapeCentre(hovered);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function reposition(tip: HTMLElement): void {
|
|
100
|
+
const host = root.value;
|
|
101
|
+
if (!host || !enabled()) return;
|
|
102
|
+
// Nothing to place while hidden, and reading layout then would force a pointless reflow.
|
|
103
|
+
if (getComputedStyle(tip).opacity === '0') return;
|
|
104
|
+
const anchor = anchorFor(host);
|
|
105
|
+
if (!anchor) return;
|
|
106
|
+
|
|
107
|
+
const w = tip.offsetWidth;
|
|
108
|
+
const h = tip.offsetHeight;
|
|
109
|
+
const box = host.getBoundingClientRect();
|
|
110
|
+
// Which way the panel opens, and the two modes want opposite answers. A crosshair spans the
|
|
111
|
+
// plot, so the empty half is the one the line is NOT in: open away from it, which also puts the
|
|
112
|
+
// panel away from the pointer. A mark sits somewhere in the chart with the rest of the chart
|
|
113
|
+
// around it, so opening inward would lay the panel over the other marks — over the donut's hole
|
|
114
|
+
// and its central label, in the case that made this obvious. Open outward instead.
|
|
115
|
+
const cx = box.x + box.width / 2;
|
|
116
|
+
const toRight = mode === 'crosshair' ? anchor.x <= cx : anchor.x > cx;
|
|
117
|
+
const left = toRight ? anchor.x + GAP : anchor.x - w - GAP;
|
|
118
|
+
// A crosshair panel rides at the top of the plot: fixed, so it never drifts vertically with a
|
|
119
|
+
// pointer whose y means nothing to the reading. A mark panel centres on its own shape.
|
|
120
|
+
const top = mode === 'crosshair' ? box.y + GAP : anchor.y - h / 2;
|
|
121
|
+
|
|
122
|
+
writing = true;
|
|
123
|
+
tip.style.left = `${clamp(left, EDGE, window.innerWidth - w - EDGE)}px`;
|
|
124
|
+
tip.style.top = `${clamp(top, EDGE, window.innerHeight - h - EDGE)}px`;
|
|
125
|
+
// Unovis positions by `bottom` when the tooltip is not attached to body; clear it so our `top`
|
|
126
|
+
// is the only vertical rule in play whichever container the frame ends up with.
|
|
127
|
+
tip.style.bottom = 'unset';
|
|
128
|
+
writing = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function attach(): void {
|
|
132
|
+
const tip = document.querySelector<HTMLElement>(`[data-forest-tip="${tipId}"]`);
|
|
133
|
+
if (!tip) {
|
|
134
|
+
// VisTooltip creates its element on its own schedule; wait for it rather than racing mount.
|
|
135
|
+
frame = requestAnimationFrame(attach);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
observer = new MutationObserver(() => {
|
|
139
|
+
if (!writing) reposition(tip);
|
|
140
|
+
});
|
|
141
|
+
observer.observe(tip, { attributes: true, attributeFilter: ['style', 'class'] });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onMounted(() => {
|
|
145
|
+
root.value?.addEventListener('pointermove', onPointerMove, { capture: true });
|
|
146
|
+
attach();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
onBeforeUnmount(() => {
|
|
150
|
+
cancelAnimationFrame(frame);
|
|
151
|
+
observer?.disconnect();
|
|
152
|
+
root.value?.removeEventListener('pointermove', onPointerMove, { capture: true });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Lets the five hover-capable chart components render in an app that has no Nuxt UI.
|
|
2
|
+
//
|
|
3
|
+
// FDistributionBar, FDottedMap, FStatusTrack, FCalendarGrid and FLondonMap each wrap their marks in
|
|
4
|
+
// one `<UTooltip>`. That tag is the ONLY thing in this package that reaches Nuxt UI, and it reaches
|
|
5
|
+
// it by NAME, not by import — so no import grep sees it, and neither does a bundle sweep.
|
|
6
|
+
//
|
|
7
|
+
// How the name resolves decides everything, and it resolves two different ways:
|
|
8
|
+
//
|
|
9
|
+
// · In a build that runs unplugin-vue-components (apps/docs does, through `@nuxt/ui/vite`), the
|
|
10
|
+
// plugin rewrites the compiled `_resolveComponent("UTooltip")` call into a direct import of
|
|
11
|
+
// @nuxt/ui's Tooltip.vue. Resolution happens at BUILD time and never consults the app's global
|
|
12
|
+
// registry, so nothing registered here can reach it — installing this plugin there is inert,
|
|
13
|
+
// and cannot take a tooltip away from the product.
|
|
14
|
+
//
|
|
15
|
+
// · In a plain Vue app with no such plugin (a Slidev deck, say), the call survives to runtime and
|
|
16
|
+
// asks the app's global components for `UTooltip`. Unanswered, Vue warns and falls back to a
|
|
17
|
+
// native `<utooltip>` element — an unknown inline box wrapped around a flex row or an SVG,
|
|
18
|
+
// which is a layout defect, not a missing tooltip.
|
|
19
|
+
//
|
|
20
|
+
// Answering that lookup with a stand-in is what this file does. The stand-in renders the trigger
|
|
21
|
+
// and nothing else: a projected slide has no pointer to hover and no presenter who could, so the
|
|
22
|
+
// tooltip is dead weight there rather than a feature lost.
|
|
23
|
+
//
|
|
24
|
+
// This is a stand-in, not a port. It carries none of Nuxt UI's tooltip behaviour — no panel, no
|
|
25
|
+
// portal, no positioning. An app that wants tooltips wants Nuxt UI.
|
|
26
|
+
import { defineComponent, type Component, type Plugin } from 'vue';
|
|
27
|
+
|
|
28
|
+
/** Renders its trigger, drops the tooltip. `inheritAttrs: false` keeps `text`, `reference` and
|
|
29
|
+
* `open` off the DOM — they are Nuxt UI props here, and as attributes they would be markup. */
|
|
30
|
+
export const TooltipStandIn: Component = defineComponent({
|
|
31
|
+
name: 'TooltipStandIn',
|
|
32
|
+
inheritAttrs: false,
|
|
33
|
+
setup(_props, { slots }) {
|
|
34
|
+
return () => {
|
|
35
|
+
const children = slots.default?.();
|
|
36
|
+
if (!children?.length) return null;
|
|
37
|
+
// One child is the shape every call site has, and returning the vnode itself rather than a
|
|
38
|
+
// one-item array keeps the trigger a single root: no fragment anchors between it and the
|
|
39
|
+
// flex or grid parent that lays it out.
|
|
40
|
+
return children.length === 1 ? children[0] : children;
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Registers the stand-ins this package's components look up by name. Install it on a Vue app that
|
|
46
|
+
* has no Nuxt UI:
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* app.use(chartsWithoutNuxtUI);
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* A name the app already answers is left alone, so installing this alongside a real Nuxt UI
|
|
53
|
+
* registration cannot shadow it. */
|
|
54
|
+
export const chartsWithoutNuxtUI: Plugin = {
|
|
55
|
+
install(app) {
|
|
56
|
+
if (!app.component('UTooltip')) app.component('UTooltip', TooltipStandIn);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The cell colour contract, shared by FStatusTrack and FCalendarGrid.
|
|
2
|
+
//
|
|
3
|
+
// Cell components answer two different questions with the same rectangle: which STATE a bucket was
|
|
4
|
+
// in, and how MUCH happened in it. Both resolve a datum to a colour and need a legend, so the
|
|
5
|
+
// resolution lives here and the components stay layout.
|
|
6
|
+
//
|
|
7
|
+
// Empty is a third outcome, not a zero and not a first state: a bucket with no reading and a bucket
|
|
8
|
+
// reading zero are different claims, and colouring them alike asserts something the data does not.
|
|
9
|
+
import { computed, unref, type MaybeRef } from 'vue';
|
|
10
|
+
import type { BulletLegendItemInterface } from '@unovis/ts';
|
|
11
|
+
import { sequentialVar } from './useChartPalette';
|
|
12
|
+
import { DATAVIZ_SEQUENTIAL } from './engine';
|
|
13
|
+
import { useThemeVersion } from './useThemeVersion';
|
|
14
|
+
|
|
15
|
+
export type SequentialRamp = keyof typeof DATAVIZ_SEQUENTIAL;
|
|
16
|
+
|
|
17
|
+
/** One legend entry. `swatch` is a var() string; intensity entries carry no name. */
|
|
18
|
+
export interface CellLegendEntry {
|
|
19
|
+
key: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
swatch: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CellLegend {
|
|
25
|
+
mode: 'state' | 'intensity';
|
|
26
|
+
entries: CellLegendEntry[];
|
|
27
|
+
/** Intensity only — the Less/More end captions. */
|
|
28
|
+
bounds?: [string, string];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CellScaleOptions<D> {
|
|
32
|
+
mode: 'state' | 'intensity';
|
|
33
|
+
/** State mode. Same shape as `categories` elsewhere, so useChartPalette output feeds it as-is. */
|
|
34
|
+
states?: MaybeRef<Record<string, BulletLegendItemInterface> | undefined>;
|
|
35
|
+
/** Intensity mode. */
|
|
36
|
+
ramp?: MaybeRef<SequentialRamp>;
|
|
37
|
+
/** Intensity domain. Defaults to [0, max over data] — state it explicitly to compare two charts. */
|
|
38
|
+
domain?: MaybeRef<[number, number] | undefined>;
|
|
39
|
+
/** Number of buckets the ramp is sampled into. */
|
|
40
|
+
steps?: MaybeRef<number>;
|
|
41
|
+
data?: MaybeRef<readonly D[]>;
|
|
42
|
+
value?: (d: D) => number | null | undefined;
|
|
43
|
+
state?: (d: D) => string | null | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CellScale<D> {
|
|
47
|
+
colorOf: (d: D | null | undefined) => string;
|
|
48
|
+
isEmpty: (d: D | null | undefined) => boolean;
|
|
49
|
+
legend: CellLegend;
|
|
50
|
+
/** The resolved intensity domain — read it back when a caller wants to label the ends. */
|
|
51
|
+
domain: [number, number];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The fill for a bucket with no reading. A token, so it flips with the mode like everything else. */
|
|
55
|
+
export const CELL_EMPTY_COLOR = 'var(--ui-bg-accented)';
|
|
56
|
+
|
|
57
|
+
const colorOf = (item: BulletLegendItemInterface | undefined, i: number): string => {
|
|
58
|
+
if (typeof item?.color === 'string') return item.color;
|
|
59
|
+
if (Array.isArray(item?.color) && typeof item.color[0] === 'string') return item.color[0];
|
|
60
|
+
return `var(--vis-color${i})`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Unlike the categorical sets, the sequential ramps are declared once and are not redefined for
|
|
64
|
+
// dark mode: a ramp runs pale → deep, which reads as low → high on a white page and backwards on a
|
|
65
|
+
// dark one, where the deep end sinks into the background. The house rule in the token file is to
|
|
66
|
+
// invert the stop order in dark mode, and doing it here means every cell chart obeys it for free.
|
|
67
|
+
const prefersInvertedRamp = (): boolean => {
|
|
68
|
+
if (typeof document === 'undefined') return false;
|
|
69
|
+
const root = document.documentElement;
|
|
70
|
+
return root.classList.contains('dark') || root.dataset.theme === 'dark';
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function useCellScale<D>(options: CellScaleOptions<D>) {
|
|
74
|
+
const stepsOf = () => Math.max(2, unref(options.steps) ?? 5);
|
|
75
|
+
// Read so the sampling recomputes on a colour-mode flip; the value itself is not used.
|
|
76
|
+
const themeVersion = useThemeVersion();
|
|
77
|
+
|
|
78
|
+
// Sampled ramp stops, low → high. sequentialVar indexes the ramp's own stops, so a request for
|
|
79
|
+
// more steps than the ramp has simply repeats stops rather than inventing colours.
|
|
80
|
+
const swatches = computed<string[]>(() => {
|
|
81
|
+
void themeVersion.value;
|
|
82
|
+
const ramp = unref(options.ramp) ?? ('forest' as SequentialRamp);
|
|
83
|
+
const len = DATAVIZ_SEQUENTIAL[ramp].length;
|
|
84
|
+
const n = stepsOf();
|
|
85
|
+
const stops = Array.from({ length: n }, (_, i) =>
|
|
86
|
+
sequentialVar(ramp, Math.round((i * (len - 1)) / (n - 1))),
|
|
87
|
+
);
|
|
88
|
+
return prefersInvertedRamp() ? stops.reverse() : stops;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const domain = computed<[number, number]>(() => {
|
|
92
|
+
const given = unref(options.domain);
|
|
93
|
+
if (given) return given;
|
|
94
|
+
const rows = unref(options.data) ?? [];
|
|
95
|
+
let max = 0;
|
|
96
|
+
for (const d of rows) {
|
|
97
|
+
const v = options.value?.(d);
|
|
98
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > max) max = v;
|
|
99
|
+
}
|
|
100
|
+
return [0, max];
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const isEmpty = (d: D | null | undefined): boolean => {
|
|
104
|
+
if (d === null || d === undefined) return true;
|
|
105
|
+
if (options.mode === 'intensity') {
|
|
106
|
+
const v = options.value?.(d);
|
|
107
|
+
return typeof v !== 'number' || !Number.isFinite(v);
|
|
108
|
+
}
|
|
109
|
+
const s = options.state?.(d);
|
|
110
|
+
return s === null || s === undefined || s === '';
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const resolve = (d: D | null | undefined): string => {
|
|
114
|
+
if (isEmpty(d)) return CELL_EMPTY_COLOR;
|
|
115
|
+
if (options.mode === 'intensity') {
|
|
116
|
+
const [lo, hi] = domain.value;
|
|
117
|
+
const v = options.value!(d as D) as number;
|
|
118
|
+
const stops = swatches.value;
|
|
119
|
+
// A flat domain has no gradient to express — everything sits at the top stop rather than
|
|
120
|
+
// dividing by zero.
|
|
121
|
+
if (hi <= lo) return stops[stops.length - 1];
|
|
122
|
+
const t = Math.min(1, Math.max(0, (v - lo) / (hi - lo)));
|
|
123
|
+
return stops[Math.min(stops.length - 1, Math.floor(t * stops.length))];
|
|
124
|
+
}
|
|
125
|
+
const states = unref(options.states) ?? {};
|
|
126
|
+
const key = options.state!(d as D) as string;
|
|
127
|
+
const keys = Object.keys(states);
|
|
128
|
+
const i = keys.indexOf(key);
|
|
129
|
+
// An unmapped state is missing information, not a colour to invent.
|
|
130
|
+
return i === -1 ? CELL_EMPTY_COLOR : colorOf(states[key], i);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const legend = computed<CellLegend>(() => {
|
|
134
|
+
if (options.mode === 'intensity') {
|
|
135
|
+
return {
|
|
136
|
+
mode: 'intensity',
|
|
137
|
+
entries: swatches.value.map((swatch, i) => ({ key: String(i), swatch })),
|
|
138
|
+
bounds: ['Less', 'More'],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const states = unref(options.states) ?? {};
|
|
142
|
+
return {
|
|
143
|
+
mode: 'state',
|
|
144
|
+
entries: Object.keys(states).map((key, i) => ({
|
|
145
|
+
key,
|
|
146
|
+
// BulletLegendItemInterface types `name` as string | number, so a numeric series name
|
|
147
|
+
// reaches the legend as a number.
|
|
148
|
+
name: String(states[key]?.name ?? key),
|
|
149
|
+
swatch: colorOf(states[key], i),
|
|
150
|
+
})),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return { colorOf: resolve, isEmpty, legend, domain, swatches };
|
|
155
|
+
}
|