@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,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A single London borough as a styleable graphic — a glyph, not a chart. Where FLondonMap fills
|
|
3
|
+
// shapes from data, this just hands you one shape, framed to itself, filled with currentColor. So
|
|
4
|
+
// it colours like an icon: `class="text-primary"`, size with a width, add a border in CSS.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately does one thing. No data, no ramp, no labels, no wards, no selection — the moment a
|
|
7
|
+
// caller wants any of those they want FLondonMap. Keeping this presentational is the whole point.
|
|
8
|
+
import { computed } from 'vue';
|
|
9
|
+
import { LONDON_BOROUGHS, indexAreas } from './londonAreas';
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(
|
|
12
|
+
defineProps<{
|
|
13
|
+
/** Which borough: an ONS code (E09…) or a name, matched case-insensitively. */
|
|
14
|
+
borough: string;
|
|
15
|
+
/** Breathing room around the shape, as a fraction of its longer side. */
|
|
16
|
+
pad?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Assistive text. With it the graphic is announced as an image; without it it is decorative and
|
|
19
|
+
* hidden from the accessibility tree — the same call an icon makes.
|
|
20
|
+
*/
|
|
21
|
+
label?: string;
|
|
22
|
+
}>(),
|
|
23
|
+
{ pad: 0.04 },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Boroughs only — the ward set is loaded on demand elsewhere, and a glyph has no reason to pull it.
|
|
27
|
+
const area = computed(() => indexAreas(LONDON_BOROUGHS.areas).get(props.borough.toLowerCase()));
|
|
28
|
+
|
|
29
|
+
// Framed to the shape's own bounds, from the stored box — no path parsing. An unknown borough draws
|
|
30
|
+
// nothing rather than a stray full-London frame.
|
|
31
|
+
const viewBox = computed(() => {
|
|
32
|
+
const a = area.value;
|
|
33
|
+
if (!a) return undefined;
|
|
34
|
+
const p = Math.max(a.w, a.h) * props.pad;
|
|
35
|
+
return `${a.x - p} ${a.y - p} ${a.w + p * 2} ${a.h + p * 2}`;
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<svg
|
|
41
|
+
v-if="area"
|
|
42
|
+
:viewBox="viewBox"
|
|
43
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
44
|
+
:role="label ? 'img' : undefined"
|
|
45
|
+
:aria-label="label"
|
|
46
|
+
:aria-hidden="label ? undefined : 'true'"
|
|
47
|
+
>
|
|
48
|
+
<!-- currentColor so a text-* class paints the fill; non-scaling-stroke keeps any border the
|
|
49
|
+
caller adds one hairline whatever the rendered size. -->
|
|
50
|
+
<path :d="area.d" fill="currentColor" vector-effect="non-scaling-stroke" />
|
|
51
|
+
</svg>
|
|
52
|
+
</template>
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
<script setup lang="ts" generic="D extends Record<string, unknown>">
|
|
2
|
+
// Days as cells: weeks are columns, weekdays are rows. A year of ride volume, or a year of one
|
|
3
|
+
// station's daily state. Colour resolution is useCellScale's; this component owns the calendar.
|
|
4
|
+
//
|
|
5
|
+
// The grid is built from the date window, not from the data, so a day with no datum is a real empty
|
|
6
|
+
// cell in the right place rather than a missing column. Days are bucketed by local calendar date —
|
|
7
|
+
// a timestamp is floored to its day before lookup, so two events on one day land together.
|
|
8
|
+
import { computed, ref } from 'vue';
|
|
9
|
+
import type { BulletLegendItemInterface } from '@unovis/ts';
|
|
10
|
+
import { useCellScale, CELL_EMPTY_COLOR, type SequentialRamp } from './useCellScale';
|
|
11
|
+
import { CELL_SIZES, orElse, type CellSize } from './cellSize';
|
|
12
|
+
import FCellLegend from './FCellLegend.vue';
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
data: D[];
|
|
17
|
+
/** The day a datum belongs to. Any Date-parseable value. */
|
|
18
|
+
date: (d: D) => Date | number | string;
|
|
19
|
+
mode?: 'state' | 'intensity';
|
|
20
|
+
states?: Record<string, BulletLegendItemInterface>;
|
|
21
|
+
state?: (d: D) => string | null | undefined;
|
|
22
|
+
value?: (d: D) => number | null | undefined;
|
|
23
|
+
ramp?: SequentialRamp;
|
|
24
|
+
/** Intensity domain. Two grids compared side by side must both set it. */
|
|
25
|
+
domain?: [number, number];
|
|
26
|
+
steps?: number;
|
|
27
|
+
/** Window bounds. Default: the first and last dates present in the data. */
|
|
28
|
+
start?: Date;
|
|
29
|
+
end?: Date;
|
|
30
|
+
/** 0 = weeks start Sunday (default), 1 = Monday. */
|
|
31
|
+
weekStart?: 0 | 1;
|
|
32
|
+
/** Size step. Sets the cell dimensions, and whether labels, legend and tooltips default on:
|
|
33
|
+
* xs and sm are too small to carry chrome, so it defaults off there. Any explicit prop wins. */
|
|
34
|
+
size?: CellSize;
|
|
35
|
+
/** Forces the size step's chrome decision either way — labels, legend and tooltips together.
|
|
36
|
+
* `monthLabels`, `weekdayLabels` and `legend` still win over it individually. */
|
|
37
|
+
chrome?: boolean;
|
|
38
|
+
cellSize?: number;
|
|
39
|
+
cellGap?: number;
|
|
40
|
+
radius?: number;
|
|
41
|
+
monthLabels?: boolean;
|
|
42
|
+
/** Rule along the month boundary. It steps day by day, so a month opening midweek is cut where
|
|
43
|
+
* it actually opens rather than at the column edge. */
|
|
44
|
+
monthSeparators?: boolean;
|
|
45
|
+
weekdayLabels?: boolean;
|
|
46
|
+
legend?: boolean;
|
|
47
|
+
/** Hover and focus text. Given one, cells become focusable buttons and get the house bubble.
|
|
48
|
+
* Suppressed at xs and sm, where the target is too small to hit reliably. */
|
|
49
|
+
tooltip?: (d: D | undefined, date: Date) => string;
|
|
50
|
+
ariaLabel?: string;
|
|
51
|
+
}>(),
|
|
52
|
+
{
|
|
53
|
+
mode: 'intensity',
|
|
54
|
+
weekStart: 0,
|
|
55
|
+
size: 'md',
|
|
56
|
+
monthSeparators: false,
|
|
57
|
+
steps: 5,
|
|
58
|
+
/* Vue casts an ABSENT Boolean-typed prop to false, so without these the size step could never
|
|
59
|
+
switch chrome on: `orElse` would read that false as a deliberate "off" at every size. An
|
|
60
|
+
explicit undefined default is what keeps "not passed" distinguishable from "passed false". */
|
|
61
|
+
monthLabels: undefined,
|
|
62
|
+
weekdayLabels: undefined,
|
|
63
|
+
legend: undefined,
|
|
64
|
+
chrome: undefined,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Three tiers, narrowest first: an individual prop beats `chrome`, which beats the size step.
|
|
69
|
+
const spec = computed(() => CELL_SIZES[props.size]);
|
|
70
|
+
const cellSize = computed(() => orElse(props.cellSize, spec.value.cell));
|
|
71
|
+
const cellGap = computed(() => orElse(props.cellGap, spec.value.gap));
|
|
72
|
+
const radius = computed(() => orElse(props.radius, spec.value.radius));
|
|
73
|
+
const showChrome = computed(() => orElse(props.chrome, spec.value.chrome));
|
|
74
|
+
const showMonthLabels = computed(() => orElse(props.monthLabels, showChrome.value));
|
|
75
|
+
const showWeekdayLabels = computed(() => orElse(props.weekdayLabels, showChrome.value));
|
|
76
|
+
const showLegend = computed(() => orElse(props.legend, showChrome.value));
|
|
77
|
+
const interactive = computed(() => !!props.tooltip && showChrome.value);
|
|
78
|
+
|
|
79
|
+
/** Local midnight — the calendar day is what a reader means, not the UTC instant. */
|
|
80
|
+
const floorDay = (v: Date | number | string): Date => {
|
|
81
|
+
const d = new Date(v);
|
|
82
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
83
|
+
};
|
|
84
|
+
const keyOf = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
85
|
+
|
|
86
|
+
// Last datum wins for a day. Summing would silently change the reading into something the caller
|
|
87
|
+
// never asked for; duplicates are the caller's to resolve.
|
|
88
|
+
const byDay = computed(() => {
|
|
89
|
+
const m = new Map<string, D>();
|
|
90
|
+
for (const d of props.data) m.set(keyOf(floorDay(props.date(d))), d);
|
|
91
|
+
return m;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const bounds = computed<[Date, Date]>(() => {
|
|
95
|
+
if (props.start && props.end) return [floorDay(props.start), floorDay(props.end)];
|
|
96
|
+
let lo = Infinity;
|
|
97
|
+
let hi = -Infinity;
|
|
98
|
+
for (const d of props.data) {
|
|
99
|
+
const t = floorDay(props.date(d)).getTime();
|
|
100
|
+
if (t < lo) lo = t;
|
|
101
|
+
if (t > hi) hi = t;
|
|
102
|
+
}
|
|
103
|
+
const today = floorDay(new Date());
|
|
104
|
+
const start = props.start ?? (Number.isFinite(lo) ? new Date(lo) : today);
|
|
105
|
+
const end = props.end ?? (Number.isFinite(hi) ? new Date(hi) : today);
|
|
106
|
+
return [floorDay(start), floorDay(end)];
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** Days back to the week boundary, so column 0 is a full week and rows line up with weekdays. */
|
|
110
|
+
const leadingOffset = (d: Date) => (d.getDay() - props.weekStart + 7) % 7;
|
|
111
|
+
|
|
112
|
+
const weeks = computed<{ date: Date; inRange: boolean }[][]>(() => {
|
|
113
|
+
const [start, end] = bounds.value;
|
|
114
|
+
const cursor = new Date(start);
|
|
115
|
+
cursor.setDate(cursor.getDate() - leadingOffset(start));
|
|
116
|
+
const out: { date: Date; inRange: boolean }[][] = [];
|
|
117
|
+
while (cursor <= end) {
|
|
118
|
+
const week: { date: Date; inRange: boolean }[] = [];
|
|
119
|
+
for (let i = 0; i < 7; i++) {
|
|
120
|
+
const day = new Date(cursor);
|
|
121
|
+
week.push({ date: day, inRange: day >= start && day <= end });
|
|
122
|
+
// setDate, not +86400000: a clocks-back day is 25 hours, so adding a fixed day and
|
|
123
|
+
// re-flooring lands on the same midnight and the walk never terminates.
|
|
124
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
125
|
+
}
|
|
126
|
+
out.push(week);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const scale = useCellScale<D>({
|
|
132
|
+
mode: props.mode,
|
|
133
|
+
states: computed(() => props.states),
|
|
134
|
+
ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
|
|
135
|
+
domain: computed(() => props.domain),
|
|
136
|
+
steps: computed(() => props.steps),
|
|
137
|
+
data: computed(() => props.data),
|
|
138
|
+
value: props.value,
|
|
139
|
+
state: props.state,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const datumFor = (d: Date) => byDay.value.get(keyOf(d));
|
|
143
|
+
const fillFor = (cell: { date: Date; inRange: boolean }) =>
|
|
144
|
+
cell.inRange ? scale.colorOf(datumFor(cell.date) ?? null) : 'transparent';
|
|
145
|
+
|
|
146
|
+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
147
|
+
/** Mon, Wed, Fri only — labelling all seven crowds an 11px row. */
|
|
148
|
+
const weekdayRows = computed(() =>
|
|
149
|
+
Array.from({ length: 7 }, (_, r) => {
|
|
150
|
+
const dayIndex = (r + props.weekStart) % 7;
|
|
151
|
+
return [1, 3, 5].includes(dayIndex) ? WEEKDAYS[dayIndex] : '';
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const monthLabel = (d: Date) => d.toLocaleDateString('en-GB', { month: 'short' });
|
|
156
|
+
|
|
157
|
+
const pitch = computed(() => cellSize.value + cellGap.value);
|
|
158
|
+
const gridWidth = computed(() => weeks.value.length * pitch.value - cellGap.value);
|
|
159
|
+
const gridHeight = computed(() => 7 * pitch.value - cellGap.value);
|
|
160
|
+
|
|
161
|
+
// Where each month opens, in grid coordinates. One source for both the label and the rule: the
|
|
162
|
+
// label used to be placed on the week whose FIRST day fell in the new month, which puts it up to
|
|
163
|
+
// six days late whenever a month opens midweek.
|
|
164
|
+
const monthStarts = computed(() => {
|
|
165
|
+
const out: { wi: number; di: number; label: string; first: boolean }[] = [];
|
|
166
|
+
let prevMonth: number | null = null;
|
|
167
|
+
weeks.value.forEach((week, wi) => {
|
|
168
|
+
week.forEach((cell, di) => {
|
|
169
|
+
if (!cell.inRange) return;
|
|
170
|
+
const m = cell.date.getMonth();
|
|
171
|
+
if (prevMonth === null) out.push({ wi, di, label: monthLabel(cell.date), first: true });
|
|
172
|
+
else if (m !== prevMonth) out.push({ wi, di, label: monthLabel(cell.date), first: false });
|
|
173
|
+
prevMonth = m;
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
return out;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// The boundary between two months, as one continuous path each.
|
|
180
|
+
//
|
|
181
|
+
// Time runs down a column then into the next, so a month that opens midweek does NOT split its
|
|
182
|
+
// column into left and right: the days above the opening row in every LATER column already belong
|
|
183
|
+
// to the new month. The frontier is therefore a Z — down the right edge of the opening column to
|
|
184
|
+
// the opening row, one column left, then down to the bottom — not a per-cell outline. A month
|
|
185
|
+
// opening on the week-start row degenerates to a single straight vertical.
|
|
186
|
+
//
|
|
187
|
+
// Coordinates land on gap midpoints (w * pitch - cellGap/2), so the rule sits centred between two
|
|
188
|
+
// cells rather than against either one, and one path per boundary welds its own corners.
|
|
189
|
+
const monthPaths = computed(() => {
|
|
190
|
+
if (!props.monthSeparators) return '';
|
|
191
|
+
const half = cellGap.value / 2;
|
|
192
|
+
return monthStarts.value
|
|
193
|
+
.filter((m) => !m.first)
|
|
194
|
+
.map(({ wi, di }) => {
|
|
195
|
+
const xLeft = wi * pitch.value - half;
|
|
196
|
+
if (di === 0) return `M${xLeft},0V${gridHeight.value}`;
|
|
197
|
+
const xRight = (wi + 1) * pitch.value - half;
|
|
198
|
+
const yCut = di * pitch.value - half;
|
|
199
|
+
return `M${xRight},0V${yCut}H${xLeft}V${gridHeight.value}`;
|
|
200
|
+
})
|
|
201
|
+
.join(' ');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const dateLabel = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
205
|
+
const labelFor = (cell: { date: Date }) => props.tooltip?.(datumFor(cell.date), cell.date) ?? '';
|
|
206
|
+
|
|
207
|
+
const gridLabel = computed(() => {
|
|
208
|
+
const [start, end] = bounds.value;
|
|
209
|
+
return props.ariaLabel ?? `Daily values from ${dateLabel(start)} to ${dateLabel(end)}`;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// One UTooltip for the grid, re-anchored to the hovered cell through `reference`. A year is 371
|
|
213
|
+
// cells; a tooltip root per cell would be 371 of them, so the anchor moves instead.
|
|
214
|
+
//
|
|
215
|
+
// `tipOpen` owns visibility; the text and the anchor are left alone on close. Clearing them
|
|
216
|
+
// together with the flag emptied the bubble mid-exit-animation and dropped the anchor back to the
|
|
217
|
+
// trigger, so leaving a segment flashed a blank pill at the container's centre for ~80ms.
|
|
218
|
+
//
|
|
219
|
+
// Anchored to the cell, not the pointer: a keyboard focus has no cursor to follow, and a bubble
|
|
220
|
+
// that tracks the pointer inside an 11px target only jitters.
|
|
221
|
+
const tip = ref<{ text: string; el: Element | null }>({ text: '', el: null });
|
|
222
|
+
const tipOpen = ref(false);
|
|
223
|
+
const showTip = (event: Event, text: string) => {
|
|
224
|
+
tip.value = { text, el: event.currentTarget as Element };
|
|
225
|
+
tipOpen.value = true;
|
|
226
|
+
};
|
|
227
|
+
const hideTip = () => {
|
|
228
|
+
tipOpen.value = false;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const hasEmpty = computed(() =>
|
|
232
|
+
weeks.value.some((w) => w.some((c) => c.inRange && scale.isEmpty(datumFor(c.date) ?? null))),
|
|
233
|
+
);
|
|
234
|
+
</script>
|
|
235
|
+
|
|
236
|
+
<template>
|
|
237
|
+
<div class="flex w-full flex-col gap-3">
|
|
238
|
+
<div class="overflow-x-auto">
|
|
239
|
+
<div class="flex" :style="{ gap: `${cellGap}px` }">
|
|
240
|
+
<!-- Weekday gutter. justify-end pins the rows to the grid's bottom, so they line up
|
|
241
|
+
whether or not a month-label row sits above. -->
|
|
242
|
+
<div
|
|
243
|
+
v-if="showWeekdayLabels"
|
|
244
|
+
class="flex shrink-0 flex-col justify-end"
|
|
245
|
+
:style="{ gap: `${cellGap}px` }"
|
|
246
|
+
>
|
|
247
|
+
<span
|
|
248
|
+
v-for="(w, r) in weekdayRows"
|
|
249
|
+
:key="r"
|
|
250
|
+
class="type-caption flex items-center text-dimmed"
|
|
251
|
+
:style="{ height: `${cellSize}px`, lineHeight: `${cellSize}px` }"
|
|
252
|
+
>{{ w }}</span>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div class="flex flex-col" :style="{ gap: `${cellGap}px` }">
|
|
256
|
+
<!-- Labels sit in their own row, positioned by column rather than living inside one, so
|
|
257
|
+
a long month name cannot widen its week and shove the cells apart. -->
|
|
258
|
+
<div
|
|
259
|
+
v-if="showMonthLabels"
|
|
260
|
+
class="relative h-5"
|
|
261
|
+
:style="{ width: `${gridWidth}px` }"
|
|
262
|
+
aria-hidden="true"
|
|
263
|
+
>
|
|
264
|
+
<span
|
|
265
|
+
v-for="m in monthStarts"
|
|
266
|
+
:key="`${m.wi}-${m.label}`"
|
|
267
|
+
class="type-caption absolute top-0 whitespace-nowrap text-dimmed"
|
|
268
|
+
:style="{ left: `${m.wi * pitch}px` }"
|
|
269
|
+
>{{ m.label }}</span>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<UTooltip :text="tip.text" :reference="tip.el" :open="tipOpen">
|
|
273
|
+
<div class="relative" :role="interactive ? 'group' : 'img'" :aria-label="gridLabel">
|
|
274
|
+
<div class="flex" :style="{ gap: `${cellGap}px` }">
|
|
275
|
+
<div v-for="(week, wi) in weeks" :key="wi" class="flex flex-col" :style="{ gap: `${cellGap}px` }">
|
|
276
|
+
<component
|
|
277
|
+
:is="interactive && cell.inRange ? 'button' : 'div'"
|
|
278
|
+
v-for="(cell, di) in week"
|
|
279
|
+
:key="di"
|
|
280
|
+
:type="interactive && cell.inRange ? 'button' : undefined"
|
|
281
|
+
class="transition-[filter] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ui-primary)]"
|
|
282
|
+
:class="interactive && cell.inRange ? 'cursor-pointer hover:brightness-110' : ''"
|
|
283
|
+
:style="{
|
|
284
|
+
width: `${cellSize}px`,
|
|
285
|
+
height: `${cellSize}px`,
|
|
286
|
+
borderRadius: `${radius}px`,
|
|
287
|
+
background: fillFor(cell),
|
|
288
|
+
}"
|
|
289
|
+
:aria-label="interactive && cell.inRange ? labelFor(cell) : undefined"
|
|
290
|
+
:aria-hidden="interactive && cell.inRange ? undefined : 'true'"
|
|
291
|
+
@mouseenter="interactive && cell.inRange && showTip($event, labelFor(cell))"
|
|
292
|
+
@mouseleave="hideTip"
|
|
293
|
+
@focus="interactive && cell.inRange && showTip($event, labelFor(cell))"
|
|
294
|
+
@blur="hideTip"
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<!-- One path for every boundary, over the cells. pointer-events none so it never takes
|
|
300
|
+
a hover from the cell beneath; crispEdges so a 1px rule stays 1px off-integer.
|
|
301
|
+
Dimmed text rather than a border token: the border tokens sit around 1.5:1 on the
|
|
302
|
+
page and vanish between saturated cells. -->
|
|
303
|
+
|
|
304
|
+
<svg
|
|
305
|
+
v-if="monthSeparators"
|
|
306
|
+
class="pointer-events-none absolute left-0 top-0 overflow-visible"
|
|
307
|
+
:width="gridWidth"
|
|
308
|
+
:height="gridHeight"
|
|
309
|
+
aria-hidden="true"
|
|
310
|
+
>
|
|
311
|
+
<path
|
|
312
|
+
:d="monthPaths"
|
|
313
|
+
fill="none"
|
|
314
|
+
stroke-width="1"
|
|
315
|
+
shape-rendering="crispEdges"
|
|
316
|
+
style="stroke: var(--ui-text-dimmed)"
|
|
317
|
+
/>
|
|
318
|
+
</svg>
|
|
319
|
+
</div>
|
|
320
|
+
</UTooltip>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<FCellLegend
|
|
326
|
+
v-if="showLegend"
|
|
327
|
+
:legend="scale.legend.value"
|
|
328
|
+
:show-empty="hasEmpty"
|
|
329
|
+
:empty-color="CELL_EMPTY_COLOR"
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The legend both cell components share. Two shapes from one contract: named entries for state
|
|
3
|
+
// mode, an unnamed Less → More ramp for intensity.
|
|
4
|
+
//
|
|
5
|
+
// The empty swatch appears only when the data actually has a gap. A legend that always advertised
|
|
6
|
+
// "no data" would teach readers to look for a category that is usually absent.
|
|
7
|
+
import type { CellLegend } from './useCellScale';
|
|
8
|
+
|
|
9
|
+
withDefaults(
|
|
10
|
+
defineProps<{
|
|
11
|
+
legend: CellLegend;
|
|
12
|
+
showEmpty?: boolean;
|
|
13
|
+
emptyColor?: string;
|
|
14
|
+
}>(),
|
|
15
|
+
{ showEmpty: false, emptyColor: 'var(--ui-bg-accented)' },
|
|
16
|
+
);
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
21
|
+
<template v-if="legend.mode === 'state'">
|
|
22
|
+
<span v-for="e in legend.entries" :key="e.key" class="flex items-center gap-1.5">
|
|
23
|
+
<span
|
|
24
|
+
class="size-2.5 rounded-full"
|
|
25
|
+
:style="{ background: e.swatch }"
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
/>
|
|
28
|
+
<span class="type-caption text-default">{{ e.name }}</span>
|
|
29
|
+
</span>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<span v-else class="flex items-center gap-1.5">
|
|
33
|
+
<span class="type-caption text-dimmed">{{ legend.bounds?.[0] }}</span>
|
|
34
|
+
<span class="flex gap-0.5" aria-hidden="true">
|
|
35
|
+
<span
|
|
36
|
+
v-for="e in legend.entries"
|
|
37
|
+
:key="e.key"
|
|
38
|
+
class="size-2.5 rounded-[2px]"
|
|
39
|
+
:style="{ background: e.swatch }"
|
|
40
|
+
/>
|
|
41
|
+
</span>
|
|
42
|
+
<span class="type-caption text-dimmed">{{ legend.bounds?.[1] }}</span>
|
|
43
|
+
</span>
|
|
44
|
+
|
|
45
|
+
<span v-if="showEmpty" class="flex items-center gap-1.5">
|
|
46
|
+
<span
|
|
47
|
+
class="size-2.5 rounded-[2px] ring-1 ring-[var(--ui-border)] ring-inset"
|
|
48
|
+
:style="{ background: emptyColor }"
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
/>
|
|
51
|
+
<span class="type-caption text-dimmed">No data</span>
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script setup lang="ts" generic="Datum extends Record<string, unknown>">
|
|
2
|
+
// The XY frame. Doctrine: wrap by rot-risk, not chart type — never wrap mark props; wrap only
|
|
3
|
+
// ceremony (container, axes, legend, tooltip/crosshair, repaint). The mark arrives through the
|
|
4
|
+
// #mark slot as real @unovis/vue components, so mark props are Unovis' own ConfigInterface,
|
|
5
|
+
// never re-enumerated here. Every library-shaped surface passes through as a typed
|
|
6
|
+
// Partial<…ConfigInterface> object prop whose runtime is a blind spread over the house preset.
|
|
7
|
+
import { computed, ref } from 'vue';
|
|
8
|
+
import { VisXYContainer, VisAxis, VisCrosshair, VisTooltip, VisBulletLegend } from '@unovis/vue';
|
|
9
|
+
import { Position } from '@unovis/ts';
|
|
10
|
+
import type {
|
|
11
|
+
AxisConfigInterface,
|
|
12
|
+
BulletLegendItemInterface,
|
|
13
|
+
TooltipConfigInterface,
|
|
14
|
+
XYContainerConfigInterface,
|
|
15
|
+
} from '@unovis/ts';
|
|
16
|
+
// Relative file imports (not the package barrel): the barrel exports this component, and a
|
|
17
|
+
// self-import through it would create a module cycle.
|
|
18
|
+
import { fromCategories } from './fromCategories';
|
|
19
|
+
import { forestTooltip, type ForestTooltipOptions } from './forestTooltip';
|
|
20
|
+
import { motionDuration } from './motionDuration';
|
|
21
|
+
import {
|
|
22
|
+
axisX as axisXPreset,
|
|
23
|
+
axisY as axisYPreset,
|
|
24
|
+
tooltipDefaults,
|
|
25
|
+
tooltipContainer,
|
|
26
|
+
} from './presets';
|
|
27
|
+
import { useThemeVersion } from './useThemeVersion';
|
|
28
|
+
import { useTooltipSnap, nextTipId } from './snapTooltip';
|
|
29
|
+
|
|
30
|
+
defineOptions({ inheritAttrs: false });
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(
|
|
33
|
+
defineProps<{
|
|
34
|
+
data: Datum[];
|
|
35
|
+
/** Palette categories (from useChartPalette) — series order, names, colours. */
|
|
36
|
+
categories: Record<string, BulletLegendItemInterface>;
|
|
37
|
+
height?: number;
|
|
38
|
+
/** Numeric field to plot on x (e.g. a timestamp). Default: the row index. */
|
|
39
|
+
xKey?: keyof Datum & string;
|
|
40
|
+
/** Blind spread onto VisXYContainer — any XYContainerConfigInterface key, presets last-wins. */
|
|
41
|
+
container?: Partial<XYContainerConfigInterface<Datum>>;
|
|
42
|
+
/** Merged over the house x-axis preset. `false` removes the axis. */
|
|
43
|
+
axisX?: Partial<AxisConfigInterface<Datum>> | false;
|
|
44
|
+
axisY?: Partial<AxisConfigInterface<Datum>> | false;
|
|
45
|
+
/** Merged over the house tooltip preset. `false` removes tooltip AND crosshair. */
|
|
46
|
+
tooltip?: Partial<TooltipConfigInterface> | false;
|
|
47
|
+
/** Let the panel track the pointer instead of snapping to the datum. Off by default: on a
|
|
48
|
+
* crosshair chart the panel locks to the crosshair's x at the top of the plot, so it steps
|
|
49
|
+
* between rows and holds still in between; without a crosshair it anchors to the hovered
|
|
50
|
+
* mark. Turn it on for a free-floating panel. */
|
|
51
|
+
followCursor?: boolean;
|
|
52
|
+
/* The `| false` unions above compile to runtime type [Object, Boolean], and Vue casts an
|
|
53
|
+
ABSENT Boolean-typed prop to `false` unless it has a default — so each carries an empty
|
|
54
|
+
object default below; without it an unpassed axis or tooltip suppresses itself. */
|
|
55
|
+
/** `false` keeps the tooltip and drops the crosshair. For marks with no continuous value at
|
|
56
|
+
* an x — a timeline row carries a span and a scatter is a cloud, so there is nothing to snap
|
|
57
|
+
* to and the tooltip fires from the mark's own `triggers` instead. */
|
|
58
|
+
crosshair?: boolean;
|
|
59
|
+
/** Title/value formatting for the house crosshair template. */
|
|
60
|
+
format?: ForestTooltipOptions<Datum>;
|
|
61
|
+
/** The mark runs horizontally (set `orientation` on the mark itself). The frame's index
|
|
62
|
+
* machinery — tick pinning, the edge gutter, grid-line defaults — follows the category
|
|
63
|
+
* dimension onto y; formatters stay yours to place per axis. */
|
|
64
|
+
horizontal?: boolean;
|
|
65
|
+
legend?: boolean;
|
|
66
|
+
/** Where the legend sits. `bottom` (default) renders centred under the chart, caption-style;
|
|
67
|
+
* `top` renders start-aligned above it, editorial-style. */
|
|
68
|
+
legendPosition?: 'top' | 'bottom';
|
|
69
|
+
/** Animation ms for the whole container (marks + axes). Default: motionDuration('slow'). */
|
|
70
|
+
duration?: number;
|
|
71
|
+
/** Extra signals that must remount the chart (theme flips and palette identity are automatic). */
|
|
72
|
+
repaintOn?: unknown[];
|
|
73
|
+
}>(),
|
|
74
|
+
{
|
|
75
|
+
height: 280,
|
|
76
|
+
horizontal: false,
|
|
77
|
+
crosshair: true,
|
|
78
|
+
followCursor: false,
|
|
79
|
+
legend: true,
|
|
80
|
+
legendPosition: 'bottom',
|
|
81
|
+
repaintOn: () => [],
|
|
82
|
+
axisX: () => ({}),
|
|
83
|
+
axisY: () => ({}),
|
|
84
|
+
tooltip: () => ({}),
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
defineSlots<{
|
|
89
|
+
mark(p: {
|
|
90
|
+
data: Datum[];
|
|
91
|
+
x: (d: Datum, i: number) => number;
|
|
92
|
+
y: Array<(d: Datum) => number | null | undefined>;
|
|
93
|
+
colors: string[];
|
|
94
|
+
}): unknown;
|
|
95
|
+
}>();
|
|
96
|
+
|
|
97
|
+
const acc = computed(() => fromCategories<Datum>(props.categories, props.data, props.xKey));
|
|
98
|
+
|
|
99
|
+
// Index charts get tick positions at the row indices — without this the axis invents fractional
|
|
100
|
+
// ticks between rows. The pin sits on the category axis: x normally, y when `horizontal` (Unovis
|
|
101
|
+
// swaps which scale carries the index). Grid lines follow the value axis the same way. An xKey
|
|
102
|
+
// chart scales its own axis.
|
|
103
|
+
const indexTicks = computed(() =>
|
|
104
|
+
props.xKey ? undefined : { tickValues: props.data.map((_, i) => i) },
|
|
105
|
+
);
|
|
106
|
+
const xAxisConfig = computed<Partial<AxisConfigInterface<Datum>>>(() => ({
|
|
107
|
+
...axisXPreset,
|
|
108
|
+
...(props.horizontal ? { gridLine: true } : indexTicks.value),
|
|
109
|
+
...(props.axisX || {}),
|
|
110
|
+
}));
|
|
111
|
+
const yAxisConfig = computed<Partial<AxisConfigInterface<Datum>>>(() => ({
|
|
112
|
+
...axisYPreset,
|
|
113
|
+
...(props.horizontal ? { gridLine: false, ...indexTicks.value } : {}),
|
|
114
|
+
...(props.axisY || {}),
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
// Index charts also get half a row of outer gutter: the category domain extends past the first
|
|
118
|
+
// and last rows so edge marks (bars especially) stop sitting flush against the plot edges. Ticks
|
|
119
|
+
// stay on the row indices, and the gutter follows the category dimension under `horizontal`. An
|
|
120
|
+
// xKey chart plots a real field and keeps the library's exact-extent default; an explicit
|
|
121
|
+
// `container` domain overrides this like any other container key.
|
|
122
|
+
const containerConfig = computed<Partial<XYContainerConfigInterface<Datum>>>(() => {
|
|
123
|
+
const gutter: [number, number] = [-0.5, props.data.length - 0.5];
|
|
124
|
+
return {
|
|
125
|
+
...(props.xKey || props.data.length === 0
|
|
126
|
+
? {}
|
|
127
|
+
: props.horizontal
|
|
128
|
+
? { yDomain: gutter }
|
|
129
|
+
: { xDomain: gutter }),
|
|
130
|
+
...(props.container || {}),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const tipFn = computed(() => forestTooltip<Datum>(props.categories, props.format));
|
|
135
|
+
|
|
136
|
+
// The panel is snapped to the datum by useTooltipSnap below, which rewrites its position after
|
|
137
|
+
// Unovis places it. What Unovis is configured to do here is therefore only what shows when the
|
|
138
|
+
// snap stands down (`followCursor`), plus the show/hide and content it always owns: pointer
|
|
139
|
+
// tracking as the base, and Auto placement so Crosshair's own left/right flip is free to run.
|
|
140
|
+
// The `data-forest-tip` attribute is how the snap finds this frame's panel among body's children,
|
|
141
|
+
// so it is merged last — a call site's own attributes are kept, but cannot displace the hook.
|
|
142
|
+
const tipId = nextTipId();
|
|
143
|
+
const tooltipConfig = computed<Partial<TooltipConfigInterface>>(() => ({
|
|
144
|
+
...tooltipDefaults,
|
|
145
|
+
container: tooltipContainer(),
|
|
146
|
+
followCursor: true,
|
|
147
|
+
...(props.crosshair ? { horizontalPlacement: Position.Auto } : {}),
|
|
148
|
+
...(props.tooltip || {}),
|
|
149
|
+
attributes: { ...(props.tooltip || {}).attributes, 'data-forest-tip': tipId },
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
const rootEl = ref<HTMLElement>();
|
|
153
|
+
useTooltipSnap(rootEl, tipId, props.crosshair ? 'crosshair' : 'mark', () => !props.followCursor);
|
|
154
|
+
|
|
155
|
+
// Charts animate on the motion tokens (reduced motion resolves to 0); container duration
|
|
156
|
+
// propagates to marks and axes. Override via the prop or container.duration.
|
|
157
|
+
const resolvedDuration = computed(() => props.duration ?? motionDuration());
|
|
158
|
+
|
|
159
|
+
// Theme flips and palette identity changes both remount: Unovis caches resolved colours on mount.
|
|
160
|
+
const themeVersion = useThemeVersion();
|
|
161
|
+
const repaintKey = computed(() =>
|
|
162
|
+
[themeVersion.value, acc.value.colors.join('|'), ...props.repaintOn].map(String).join('-'),
|
|
163
|
+
);
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<template>
|
|
167
|
+
<div ref="rootEl" style="display: flex; flex-direction: column; gap: 0.75rem">
|
|
168
|
+
<VisXYContainer
|
|
169
|
+
:key="repaintKey"
|
|
170
|
+
:data="data"
|
|
171
|
+
:height="height"
|
|
172
|
+
:duration="resolvedDuration"
|
|
173
|
+
v-bind="{ ...containerConfig, ...$attrs }"
|
|
174
|
+
>
|
|
175
|
+
<slot name="mark" :data="data" :x="acc.xAccessor" :y="acc.yAccessors" :colors="acc.colors" />
|
|
176
|
+
<VisAxis v-if="axisX !== false" v-bind="xAxisConfig" />
|
|
177
|
+
<VisAxis v-if="axisY !== false" v-bind="yAxisConfig" />
|
|
178
|
+
<template v-if="tooltip !== false">
|
|
179
|
+
<!-- duration 0 is deliberate, and load-bearing twice over. Unovis animates the crosshair
|
|
180
|
+
line's x through a transition, so it eases toward the row the pointer just snapped to
|
|
181
|
+
— which reads as lag on the one element that should track the pointer exactly, and
|
|
182
|
+
leaves the line's real position mid-flight while the snap is reading it as the
|
|
183
|
+
tooltip's anchor. Easing it would put the panel on the wrong side of the plot. -->
|
|
184
|
+
<VisCrosshair
|
|
185
|
+
v-if="crosshair"
|
|
186
|
+
:x="acc.xAccessor"
|
|
187
|
+
:y="acc.yAccessors"
|
|
188
|
+
:color="acc.colors"
|
|
189
|
+
:template="tipFn"
|
|
190
|
+
:duration="0"
|
|
191
|
+
/>
|
|
192
|
+
<VisTooltip v-bind="tooltipConfig" />
|
|
193
|
+
</template>
|
|
194
|
+
</VisXYContainer>
|
|
195
|
+
<VisBulletLegend
|
|
196
|
+
v-if="legend"
|
|
197
|
+
:items="acc.legendItems"
|
|
198
|
+
:style="legendPosition === 'top' ? { order: -1, alignSelf: 'flex-start' } : { alignSelf: 'center' }"
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
</template>
|