@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.
Files changed (324) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +208 -0
  3. package/app.config.ts +10 -0
  4. package/assets/icons/forest/bike-asterisk.svg +21 -0
  5. package/assets/icons/forest/bike-down.svg +19 -0
  6. package/assets/icons/forest/bike-off.svg +20 -0
  7. package/assets/icons/forest/bike-up.svg +19 -0
  8. package/assets/icons/forest/bike-x.svg +19 -0
  9. package/assets/icons/forest/bike.svg +18 -0
  10. package/assets/icons/forest/mark-fill.svg +6 -0
  11. package/assets/icons/forest/mark.svg +6 -0
  12. package/assets/icons/forest/parking.svg +3 -0
  13. package/assets/icons/forest/star-fill.svg +4 -0
  14. package/assets/icons/forest/star.svg +4 -0
  15. package/assets/icons/forest/traffic-light-caution.svg +8 -0
  16. package/assets/icons/forest/traffic-light-go.svg +8 -0
  17. package/assets/icons/forest/traffic-light-lit.svg +8 -0
  18. package/assets/icons/forest/traffic-light-stop.svg +8 -0
  19. package/assets/icons/forest/traffic-light.svg +8 -0
  20. package/nuxt.config.ts +81 -0
  21. package/package.json +35 -0
  22. package/packages/charts/src/FBoroughShape.vue +52 -0
  23. package/packages/charts/src/FCalendarGrid.vue +332 -0
  24. package/packages/charts/src/FCellLegend.vue +54 -0
  25. package/packages/charts/src/FChartFrame.vue +201 -0
  26. package/packages/charts/src/FChartFrameSingle.vue +91 -0
  27. package/packages/charts/src/FChartLegend.vue +26 -0
  28. package/packages/charts/src/FDistributionBar.vue +224 -0
  29. package/packages/charts/src/FDottedMap.vue +335 -0
  30. package/packages/charts/src/FLondonMap.vue +399 -0
  31. package/packages/charts/src/FSparkline.vue +198 -0
  32. package/packages/charts/src/FStatusTrack.vue +151 -0
  33. package/packages/charts/src/cellSize.ts +46 -0
  34. package/packages/charts/src/distribution.ts +99 -0
  35. package/packages/charts/src/engine.ts +15 -0
  36. package/packages/charts/src/forestTooltip.ts +110 -0
  37. package/packages/charts/src/fromCategories.ts +43 -0
  38. package/packages/charts/src/index.ts +87 -0
  39. package/packages/charts/src/londonAreas.ts +75 -0
  40. package/packages/charts/src/londonGrid.ts +67 -0
  41. package/packages/charts/src/motionDuration.ts +49 -0
  42. package/packages/charts/src/presets.ts +143 -0
  43. package/packages/charts/src/resolveVar.ts +21 -0
  44. package/packages/charts/src/snapTooltip.ts +154 -0
  45. package/packages/charts/src/tooltipStandIn.ts +58 -0
  46. package/packages/charts/src/useCellScale.ts +155 -0
  47. package/packages/charts/src/useChartPalette.ts +61 -0
  48. package/packages/charts/src/useChartRepaintKey.ts +22 -0
  49. package/packages/charts/src/useThemeVersion.ts +17 -0
  50. package/packages/frames/src/FBrowserFrame.vue +53 -0
  51. package/packages/frames/src/FDeviceFrame.vue +242 -0
  52. package/packages/frames/src/FFitFrame.vue +83 -0
  53. package/packages/frames/src/art/iphone-11.svg +124 -0
  54. package/packages/frames/src/art/iphone-12.svg +601 -0
  55. package/packages/frames/src/art/iphone-13-pro.svg +820 -0
  56. package/packages/frames/src/art/iphone-13.svg +849 -0
  57. package/packages/frames/src/art/iphone-14.svg +681 -0
  58. package/packages/frames/src/art/iphone-15-pro.svg +633 -0
  59. package/packages/frames/src/art/iphone-15.svg +713 -0
  60. package/packages/frames/src/art/iphone-16-plus.svg +685 -0
  61. package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
  62. package/packages/frames/src/art/iphone-16-pro.svg +631 -0
  63. package/packages/frames/src/art/iphone-16.svg +713 -0
  64. package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
  65. package/packages/frames/src/art/iphone-17.svg +305 -0
  66. package/packages/frames/src/art/iphone-air.svg +389 -0
  67. package/packages/frames/src/art/iphone-se-3.svg +473 -0
  68. package/packages/frames/src/art/iphone-x.svg +652 -0
  69. package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
  70. package/packages/frames/src/art/pixel-10-pro.svg +32 -0
  71. package/packages/frames/src/art/pixel-10.svg +32 -0
  72. package/packages/frames/src/art/pixel-8-pro.svg +208 -0
  73. package/packages/frames/src/art/pixel-8.svg +208 -0
  74. package/packages/frames/src/art/pixel-8a.svg +8 -0
  75. package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
  76. package/packages/frames/src/art/pixel-9-pro.svg +34 -0
  77. package/packages/frames/src/art/pixel-9a.svg +25 -0
  78. package/packages/frames/src/art.ts +29 -0
  79. package/packages/frames/src/devices.ts +337 -0
  80. package/packages/frames/src/index.ts +15 -0
  81. package/packages/tokens/geo/london-boroughs.json +1 -0
  82. package/packages/tokens/geo/london-dots-coarse.json +1 -0
  83. package/packages/tokens/geo/london-dots-fine.json +1 -0
  84. package/packages/tokens/geo/london-dots-medium.json +1 -0
  85. package/packages/tokens/geo/london-dots-ultra.json +1 -0
  86. package/packages/tokens/geo/london-wards.json +1 -0
  87. package/packages/tokens/glyphs/glyphs.json +1 -0
  88. package/packages/tokens/logos/f-rot.svg +4 -0
  89. package/packages/tokens/logos/f.svg +4 -0
  90. package/packages/tokens/logos/favicon-c2w.svg +9 -0
  91. package/packages/tokens/logos/favicon-console.svg +9 -0
  92. package/packages/tokens/logos/favicon-ds.svg +9 -0
  93. package/packages/tokens/logos/favicon-fleet.svg +9 -0
  94. package/packages/tokens/logos/favicon-flex.svg +9 -0
  95. package/packages/tokens/logos/favicon-plus.svg +9 -0
  96. package/packages/tokens/logos/favicon-radar.svg +9 -0
  97. package/packages/tokens/logos/favicon-tower.svg +9 -0
  98. package/packages/tokens/logos/lockup-horizontal.svg +17 -0
  99. package/packages/tokens/logos/lockup-vertical.svg +17 -0
  100. package/packages/tokens/logos/mark-mono.svg +4 -0
  101. package/packages/tokens/logos/mark.svg +7 -0
  102. package/packages/tokens/logos/wordmark.svg +9 -0
  103. package/packages/tokens/src/colourEngine.ts +604 -0
  104. package/packages/tokens/src/glyphs.ts +127 -0
  105. package/packages/tokens/src/logo.ts +50 -0
  106. package/packages/tokens/src/subbrands.ts +44 -0
  107. package/packages/tokens/src/sublogo.ts +80 -0
  108. package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
  109. package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
  110. package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
  111. package/packages/ui/icons/forest/map/manifest.json +373 -0
  112. package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
  113. package/packages/ui/icons/forest/map/place-manifest.json +74 -0
  114. package/packages/ui/src/address/address.ts +125 -0
  115. package/packages/ui/src/address/autofill.ts +78 -0
  116. package/packages/ui/src/address/geocode.ts +55 -0
  117. package/packages/ui/src/address/index.ts +31 -0
  118. package/packages/ui/src/address/validate.ts +91 -0
  119. package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
  120. package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
  121. package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
  122. package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
  123. package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
  124. package/packages/ui/src/canvas/canvasSurface.ts +39 -0
  125. package/packages/ui/src/canvas/index.ts +17 -0
  126. package/packages/ui/src/contexts/ForestContext.vue +53 -0
  127. package/packages/ui/src/contexts/contexts.ts +153 -0
  128. package/packages/ui/src/contexts/index.ts +23 -0
  129. package/packages/ui/src/contexts/marketing-theme.ts +214 -0
  130. package/packages/ui/src/contexts/mobile-theme.ts +402 -0
  131. package/packages/ui/src/filter/FFilterBar.vue +72 -0
  132. package/packages/ui/src/filter/FFilterChip.vue +362 -0
  133. package/packages/ui/src/filter/FFilterClauses.vue +104 -0
  134. package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
  135. package/packages/ui/src/filter/filter.theme.ts +27 -0
  136. package/packages/ui/src/filter/filterClause.ts +265 -0
  137. package/packages/ui/src/filter/filterPredicate.ts +134 -0
  138. package/packages/ui/src/filter/filterSchema.ts +82 -0
  139. package/packages/ui/src/filter/index.ts +36 -0
  140. package/packages/ui/src/forest-preset.ts +68 -0
  141. package/packages/ui/src/icons.ts +79 -0
  142. package/packages/ui/src/index.ts +17 -0
  143. package/packages/ui/src/kpi/FKpi.vue +253 -0
  144. package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
  145. package/packages/ui/src/kpi/index.ts +20 -0
  146. package/packages/ui/src/kpi/kpi.theme.ts +168 -0
  147. package/packages/ui/src/kpi/kpiDelta.ts +82 -0
  148. package/packages/ui/src/kpi/kpiFormat.ts +72 -0
  149. package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
  150. package/packages/ui/src/logo/FLogo.vue +52 -0
  151. package/packages/ui/src/logo/FSubLogo.vue +214 -0
  152. package/packages/ui/src/logo/index.ts +8 -0
  153. package/packages/ui/src/map/FMap.vue +355 -0
  154. package/packages/ui/src/map/FMapCompass.vue +154 -0
  155. package/packages/ui/src/map/FMapCoordinates.vue +134 -0
  156. package/packages/ui/src/map/FMapDrawTools.vue +116 -0
  157. package/packages/ui/src/map/FMapFullscreen.vue +92 -0
  158. package/packages/ui/src/map/FMapGeolocate.vue +208 -0
  159. package/packages/ui/src/map/FMapLayers.vue +75 -0
  160. package/packages/ui/src/map/FMapLegend.vue +45 -0
  161. package/packages/ui/src/map/FMapPopup.vue +129 -0
  162. package/packages/ui/src/map/FMapRamp.vue +69 -0
  163. package/packages/ui/src/map/FMapRegion.vue +77 -0
  164. package/packages/ui/src/map/FMapScale.vue +107 -0
  165. package/packages/ui/src/map/FMapSearch.vue +362 -0
  166. package/packages/ui/src/map/FMapTilt.vue +87 -0
  167. package/packages/ui/src/map/FMapTools.vue +61 -0
  168. package/packages/ui/src/map/FMapZoom.vue +81 -0
  169. package/packages/ui/src/map/FMarker.vue +218 -0
  170. package/packages/ui/src/map/FTrackScrubber.vue +94 -0
  171. package/packages/ui/src/map/FVehicleLegend.vue +72 -0
  172. package/packages/ui/src/map/FVehicleMark.vue +166 -0
  173. package/packages/ui/src/map/bin.ts +196 -0
  174. package/packages/ui/src/map/coordinates.ts +83 -0
  175. package/packages/ui/src/map/density.ts +401 -0
  176. package/packages/ui/src/map/draw.ts +356 -0
  177. package/packages/ui/src/map/ease.ts +67 -0
  178. package/packages/ui/src/map/fullscreen.ts +59 -0
  179. package/packages/ui/src/map/heat.ts +160 -0
  180. package/packages/ui/src/map/index.ts +351 -0
  181. package/packages/ui/src/map/liveMarkers.ts +60 -0
  182. package/packages/ui/src/map/map.css +116 -0
  183. package/packages/ui/src/map/mapColor.ts +31 -0
  184. package/packages/ui/src/map/mapContext.ts +12 -0
  185. package/packages/ui/src/map/mapLayers.ts +55 -0
  186. package/packages/ui/src/map/mapLegend.ts +70 -0
  187. package/packages/ui/src/map/mapSearch.theme.ts +79 -0
  188. package/packages/ui/src/map/mapSearch.ts +279 -0
  189. package/packages/ui/src/map/mapUrl.ts +117 -0
  190. package/packages/ui/src/map/markerAsset.ts +3 -0
  191. package/packages/ui/src/map/padding.ts +39 -0
  192. package/packages/ui/src/map/place.ts +192 -0
  193. package/packages/ui/src/map/ramp.ts +95 -0
  194. package/packages/ui/src/map/regionSelect.ts +163 -0
  195. package/packages/ui/src/map/route.ts +167 -0
  196. package/packages/ui/src/map/scale.ts +329 -0
  197. package/packages/ui/src/map/scaleBar.ts +67 -0
  198. package/packages/ui/src/map/selectionModel.ts +50 -0
  199. package/packages/ui/src/map/sources/coordinates.ts +58 -0
  200. package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
  201. package/packages/ui/src/map/style.ts +14 -0
  202. package/packages/ui/src/map/track.ts +279 -0
  203. package/packages/ui/src/map/useFeatureSelect.ts +230 -0
  204. package/packages/ui/src/map/useLiveMarkers.ts +84 -0
  205. package/packages/ui/src/map/useMap.ts +150 -0
  206. package/packages/ui/src/map/useMapDraw.ts +776 -0
  207. package/packages/ui/src/map/useMapOverlay.ts +421 -0
  208. package/packages/ui/src/map/useMapTableSelection.ts +101 -0
  209. package/packages/ui/src/map/useMapUrlState.ts +155 -0
  210. package/packages/ui/src/map/useMarkerSource.ts +163 -0
  211. package/packages/ui/src/map/useRegionSelect.ts +309 -0
  212. package/packages/ui/src/map/useTokenMode.ts +61 -0
  213. package/packages/ui/src/map/useTrackPlayback.ts +131 -0
  214. package/packages/ui/src/map/useVehicleFilter.ts +51 -0
  215. package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
  216. package/packages/ui/src/map/vehicle.ts +807 -0
  217. package/packages/ui/src/map/vehicleFilter.ts +284 -0
  218. package/packages/ui/src/map/vehicleLegend.ts +155 -0
  219. package/packages/ui/src/map/visibleFeatures.ts +192 -0
  220. package/packages/ui/src/map/zone.ts +291 -0
  221. package/packages/ui/src/shell/FAppNavbar.vue +68 -0
  222. package/packages/ui/src/shell/FAppShell.vue +163 -0
  223. package/packages/ui/src/shell/FAppSidebar.vue +215 -0
  224. package/packages/ui/src/shell/FPageToolbar.vue +126 -0
  225. package/packages/ui/src/shell/FScrollShadow.vue +43 -0
  226. package/packages/ui/src/shell/FSidePanel.vue +258 -0
  227. package/packages/ui/src/shell/FToolbarActions.vue +32 -0
  228. package/packages/ui/src/shell/index.ts +33 -0
  229. package/packages/ui/src/shell/nav.ts +37 -0
  230. package/packages/ui/src/shell/pageToolbar.ts +69 -0
  231. package/packages/ui/src/shell/shell.ts +74 -0
  232. package/packages/ui/src/shell/sidePanel.ts +115 -0
  233. package/packages/ui/src/themes/accordion.ts +36 -0
  234. package/packages/ui/src/themes/alert.ts +16 -0
  235. package/packages/ui/src/themes/auth-form.ts +19 -0
  236. package/packages/ui/src/themes/avatar-group.ts +9 -0
  237. package/packages/ui/src/themes/avatar.ts +18 -0
  238. package/packages/ui/src/themes/badge.ts +51 -0
  239. package/packages/ui/src/themes/banner.ts +21 -0
  240. package/packages/ui/src/themes/breadcrumb.ts +10 -0
  241. package/packages/ui/src/themes/button.mechanics.ts +191 -0
  242. package/packages/ui/src/themes/button.ts +136 -0
  243. package/packages/ui/src/themes/calendar.ts +29 -0
  244. package/packages/ui/src/themes/card.ts +67 -0
  245. package/packages/ui/src/themes/carousel.ts +41 -0
  246. package/packages/ui/src/themes/checkbox-group.ts +20 -0
  247. package/packages/ui/src/themes/checkbox.ts +47 -0
  248. package/packages/ui/src/themes/chip.ts +50 -0
  249. package/packages/ui/src/themes/collapsible.ts +26 -0
  250. package/packages/ui/src/themes/color-picker.ts +13 -0
  251. package/packages/ui/src/themes/command-palette.ts +37 -0
  252. package/packages/ui/src/themes/container.ts +14 -0
  253. package/packages/ui/src/themes/context-menu.ts +38 -0
  254. package/packages/ui/src/themes/dashboard-group.ts +11 -0
  255. package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
  256. package/packages/ui/src/themes/dashboard-panel.ts +18 -0
  257. package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
  258. package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
  259. package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
  260. package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
  261. package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
  262. package/packages/ui/src/themes/drawer.ts +45 -0
  263. package/packages/ui/src/themes/dropdown-menu.ts +38 -0
  264. package/packages/ui/src/themes/empty.ts +43 -0
  265. package/packages/ui/src/themes/field-group.ts +52 -0
  266. package/packages/ui/src/themes/file-upload.ts +38 -0
  267. package/packages/ui/src/themes/footer-columns.ts +13 -0
  268. package/packages/ui/src/themes/footer.ts +9 -0
  269. package/packages/ui/src/themes/form-field.ts +25 -0
  270. package/packages/ui/src/themes/form.ts +22 -0
  271. package/packages/ui/src/themes/header.ts +12 -0
  272. package/packages/ui/src/themes/icon.ts +61 -0
  273. package/packages/ui/src/themes/index.ts +99 -0
  274. package/packages/ui/src/themes/input-date.ts +73 -0
  275. package/packages/ui/src/themes/input-menu.ts +66 -0
  276. package/packages/ui/src/themes/input-number.ts +58 -0
  277. package/packages/ui/src/themes/input-rating.ts +25 -0
  278. package/packages/ui/src/themes/input-tags.ts +63 -0
  279. package/packages/ui/src/themes/input-time.ts +70 -0
  280. package/packages/ui/src/themes/input.ts +148 -0
  281. package/packages/ui/src/themes/kbd.ts +27 -0
  282. package/packages/ui/src/themes/link.ts +27 -0
  283. package/packages/ui/src/themes/listbox.ts +48 -0
  284. package/packages/ui/src/themes/main.ts +6 -0
  285. package/packages/ui/src/themes/marquee.ts +45 -0
  286. package/packages/ui/src/themes/modal.ts +46 -0
  287. package/packages/ui/src/themes/navigation-menu.ts +80 -0
  288. package/packages/ui/src/themes/page-anchors.ts +7 -0
  289. package/packages/ui/src/themes/page-aside.ts +7 -0
  290. package/packages/ui/src/themes/page-card.ts +22 -0
  291. package/packages/ui/src/themes/page-cta.ts +18 -0
  292. package/packages/ui/src/themes/page-feature.ts +13 -0
  293. package/packages/ui/src/themes/page-header.ts +16 -0
  294. package/packages/ui/src/themes/page-hero.ts +15 -0
  295. package/packages/ui/src/themes/page-links.ts +12 -0
  296. package/packages/ui/src/themes/page-section.ts +14 -0
  297. package/packages/ui/src/themes/page.ts +7 -0
  298. package/packages/ui/src/themes/pagination.ts +17 -0
  299. package/packages/ui/src/themes/pin-input.ts +73 -0
  300. package/packages/ui/src/themes/popover.ts +30 -0
  301. package/packages/ui/src/themes/progress.ts +29 -0
  302. package/packages/ui/src/themes/prose.ts +45 -0
  303. package/packages/ui/src/themes/radio-group.ts +39 -0
  304. package/packages/ui/src/themes/scroll-area.ts +23 -0
  305. package/packages/ui/src/themes/select-menu.ts +71 -0
  306. package/packages/ui/src/themes/select.ts +72 -0
  307. package/packages/ui/src/themes/separator.ts +28 -0
  308. package/packages/ui/src/themes/sidebar.ts +127 -0
  309. package/packages/ui/src/themes/skeleton.ts +15 -0
  310. package/packages/ui/src/themes/slideover.ts +87 -0
  311. package/packages/ui/src/themes/slider.ts +48 -0
  312. package/packages/ui/src/themes/stepper.ts +41 -0
  313. package/packages/ui/src/themes/switch.ts +63 -0
  314. package/packages/ui/src/themes/table.ts +34 -0
  315. package/packages/ui/src/themes/tabs.ts +74 -0
  316. package/packages/ui/src/themes/textarea.ts +77 -0
  317. package/packages/ui/src/themes/timeline.ts +25 -0
  318. package/packages/ui/src/themes/toast.ts +29 -0
  319. package/packages/ui/src/themes/toaster.ts +66 -0
  320. package/packages/ui/src/themes/tooltip.ts +31 -0
  321. package/packages/ui/src/themes/tree.ts +41 -0
  322. package/packages/ui/src/themes/user.ts +28 -0
  323. package/styles.cdn.gen.css +2047 -0
  324. package/theme.gen.json +1 -0
@@ -0,0 +1,399 @@
1
+ <script setup lang="ts" generic="D extends Record<string, unknown>">
2
+ // London as vector areas — boroughs or wards — filled from data. A choropleth, where the dotted map
3
+ // is a density picture: this one answers "which areas", and every shape is a real administrative
4
+ // boundary a reader can name.
5
+ //
6
+ // Colour resolution is useCellScale's, the same core the other cell components use, so a map and a
7
+ // calendar of the same data agree. What this owns is the geometry, the labels and the selection.
8
+ //
9
+ // Still not a substitute for Mapbox. There is no pan, no zoom and nothing outside London.
10
+ import { computed, ref, shallowRef, watchEffect } from 'vue';
11
+ import type { BulletLegendItemInterface } from '@unovis/ts';
12
+ import { useCellScale, CELL_EMPTY_COLOR, type SequentialRamp } from './useCellScale';
13
+ import { CELL_SIZES, orElse, type CellSize } from './cellSize';
14
+ import {
15
+ LONDON_BOROUGHS,
16
+ loadLondonWards,
17
+ indexAreas,
18
+ type AreaLevel,
19
+ type LondonArea,
20
+ type LondonAreaSet,
21
+ } from './londonAreas';
22
+ import FCellLegend from './FCellLegend.vue';
23
+
24
+ interface AreaDatum {
25
+ area: LondonArea;
26
+ datum: D | undefined;
27
+ }
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ /** Which boundaries to draw. Wards are loaded on demand — they are a separate chunk. */
32
+ level?: AreaLevel;
33
+ data?: D[];
34
+ /** The area a datum belongs to: an ONS code, or a name. Matched case-insensitively. */
35
+ area?: (d: D) => string;
36
+ mode?: 'state' | 'intensity';
37
+ states?: Record<string, BulletLegendItemInterface>;
38
+ state?: (d: D) => string | null | undefined;
39
+ value?: (d: D) => number | null | undefined;
40
+ ramp?: SequentialRamp;
41
+ /** Intensity domain. Two maps compared side by side must both set it. */
42
+ domain?: [number, number];
43
+ steps?: number;
44
+ /** How a value is spaced along the ramp. Counts over areas are usually skewed. */
45
+ scale?: 'linear' | 'sqrt' | 'log';
46
+ /**
47
+ * What the map holds: the whole city, or only the areas that carry a datum.
48
+ *
49
+ * · `city` every area at this `level`, in the projection's own frame. The default, and the
50
+ * reading a choropleth wants: an area with no datum is a fact about the data.
51
+ * · `selection` ONLY the areas with a datum, framed to the union of their own bounding boxes.
52
+ *
53
+ * ★★ IT DROPS THE REST AND RE-FRAMES, AND THE TWO GO TOGETHER. Re-framing while keeping the
54
+ * city is a zoom; dropping the city while keeping the frame is a handful of shapes adrift in an
55
+ * empty London-sized box. Neither is a picture anybody asked for.
56
+ *
57
+ * ⚠ WHAT IT COSTS: without the other areas there is no coastline, no neighbour and no river to
58
+ * locate the selection against, so it only reads if the selection is itself a familiar shape. A
59
+ * contiguous service area survives it; three boroughs that do not touch do not. The areas keep
60
+ * their true relative position either way — this is still the map's coordinate space, which is
61
+ * the difference between it and `FBoroughShape`, whose glyphs are each framed to themselves.
62
+ *
63
+ * With no datum anywhere it falls back to the city: an empty frame renders nothing at all, which
64
+ * reads as a broken component rather than as an empty state.
65
+ */
66
+ fit?: 'city' | 'selection';
67
+ /** Size step: sets the rendered width, and whether labels, legend and tooltips default on. */
68
+ size?: CellSize;
69
+ /** Forces the size step's chrome decision. Individual props still win over it. */
70
+ chrome?: boolean;
71
+ /** Area names at their centroids. Off for wards at any size — 704 labels is a texture. */
72
+ labels?: boolean;
73
+ legend?: boolean;
74
+ /** Hover text. It also puts boroughs on the keyboard; wards stay off it unless selectable. */
75
+ tooltip?: (area: LondonArea, datum: D | undefined) => string;
76
+ /** Draw the borough boundaries over a ward map, so wards read as grouped. */
77
+ boroughOutline?: boolean;
78
+ /** Selected area codes. Pair with `v-model:selected` to let a click change them. */
79
+ selected?: string[];
80
+ /** Click to select. Without it the map is a picture and areas take no clicks. */
81
+ selectable?: boolean;
82
+ ariaLabel?: string;
83
+ }>(),
84
+ {
85
+ level: 'borough',
86
+ fit: 'city',
87
+ mode: 'intensity',
88
+ steps: 5,
89
+ scale: 'linear',
90
+ size: 'md',
91
+ data: () => [],
92
+ /* Vue casts an ABSENT Boolean-typed prop to false, so without these the size step could never
93
+ switch chrome on: `orElse` would read that false as a deliberate "off" at every size. */
94
+ labels: undefined,
95
+ legend: undefined,
96
+ chrome: undefined,
97
+ boroughOutline: undefined,
98
+ },
99
+ );
100
+
101
+ const emit = defineEmits<{ 'update:selected': [string[]] }>();
102
+
103
+ // ---- geometry ----
104
+
105
+ const wards = shallowRef<LondonAreaSet | null>(null);
106
+ watchEffect(() => {
107
+ if (props.level === 'ward' && !wards.value) void loadLondonWards().then((w) => (wards.value = w));
108
+ });
109
+
110
+ // Boroughs are the fallback while the wards are in flight, so the map never flashes empty — the
111
+ // outline is the same shape either way, only the subdivision changes.
112
+ const set = computed<LondonAreaSet>(() =>
113
+ props.level === 'ward' ? (wards.value ?? LONDON_BOROUGHS) : LONDON_BOROUGHS,
114
+ );
115
+ const loading = computed(() => props.level === 'ward' && !wards.value);
116
+
117
+ const spec = computed(() => CELL_SIZES[props.size]);
118
+ // A map scales as a whole rather than by cell, so the size step drives its width. The numbers are
119
+ // the dotted map's widths, so the two components sit at the same scale on a page.
120
+ const MAP_WIDTH: Record<CellSize, number> = { xs: 192, sm: 383, md: 575, lg: 767 };
121
+ const width = computed(() => MAP_WIDTH[props.size]);
122
+ /* The frame's aspect, not the city's: a fitted map is a different shape and would otherwise be drawn
123
+ into the city's box and letterboxed by its own viewBox. */
124
+ const height = computed(() => Math.round((width.value * frame.value[3]!) / frame.value[2]!));
125
+
126
+ const showChrome = computed(() => orElse(props.chrome, spec.value.chrome));
127
+ const showLegend = computed(() => orElse(props.legend, showChrome.value));
128
+ // Ward labels are never on by default: 704 names at any size is a texture, not a label set.
129
+ const showLabels = computed(() =>
130
+ orElse(props.labels, showChrome.value && props.level === 'borough'),
131
+ );
132
+ const showBoroughOutline = computed(() => orElse(props.boroughOutline, props.level === 'ward'));
133
+ const interactive = computed(() => (!!props.tooltip || props.selectable) && showChrome.value);
134
+
135
+ // Focus is not given out as freely as hover.
136
+ //
137
+ // Selection is a real action, so a selectable map is keyboard-reachable however many areas it has —
138
+ // the caller asked for it and there is no other way to perform it. A tooltip is not an action, and
139
+ // at ward level it would buy 704 tab stops for text the summary on the wrapper already carries. So
140
+ // tooltip alone reaches the keyboard at borough level, where 33 stops is navigation, and not at
141
+ // ward level, where it would only be an obstacle.
142
+ const focusable = computed(
143
+ () => interactive.value && (props.selectable || props.level === 'borough'),
144
+ );
145
+
146
+ // Strokes invert with the mode rather than matching the page.
147
+ //
148
+ // They used to be --ui-bg, which is the page colour: on a light page a near-white fill met a white
149
+ // edge and the shape lost its outline exactly where the ramp was palest. --ui-text-muted is dark on
150
+ // a light page and light on a dark one, so an area is always bounded whatever it is filled with, and
151
+ // it is the muted step rather than the full inversion because 704 hairlines at full contrast read as
152
+ // a mesh drawn over the data. The borough outline does take the full inversion — it has to win
153
+ // against the ward edges beneath it — and selection moves to the brand colour, so being picked
154
+ // cannot be confused with being bounded.
155
+
156
+ /** Stroke width in viewBox units, so it stays one hairline whatever the rendered size. */
157
+ const strokeScale = computed(() => frame.value[2]! / width.value);
158
+
159
+ // ---- data ----
160
+
161
+ const index = computed(() => indexAreas(set.value.areas));
162
+
163
+ // Last datum wins for an area. Summing would silently change the reading into something the caller
164
+ // never asked for; duplicates are the caller's to resolve.
165
+ const byArea = computed(() => {
166
+ const m = new Map<string, D>();
167
+ if (!props.area) return m;
168
+ for (const d of props.data) {
169
+ const found = index.value.get(String(props.area(d)).toLowerCase());
170
+ if (found) m.set(found.code, d);
171
+ }
172
+ return m;
173
+ });
174
+
175
+ const cells = computed<AreaDatum[]>(() =>
176
+ set.value.areas.map((area) => ({ area, datum: byArea.value.get(area.code) })),
177
+ );
178
+
179
+ /*
180
+ ── `fit: selection` ────────────────────────────────────────────────────────────────────────────
181
+
182
+ ★★ THE SUBSET AND THE FRAME ARE ONE DECISION, and both are derived from the SAME list — the cells
183
+ that carry a datum. Deriving them separately is how a map ends up framed to one set and drawn from
184
+ another.
185
+
186
+ ⚠ `cells` ITSELF IS NOT FILTERED, and that is deliberate: the ramp's domain, the empty-state check
187
+ and the summary all read it, and a domain computed from the drawn subset would re-scale the colours
188
+ every time the frame changed. What the caller asked for is a different VIEW of the same reading.
189
+ */
190
+ const FIT_PAD = 0.06;
191
+ const fitted = computed(() => props.fit === 'selection' && cells.value.some((c) => c.datum !== undefined));
192
+ const drawn = computed(() => (fitted.value ? cells.value.filter((c) => c.datum !== undefined) : cells.value));
193
+ /*
194
+ The union of the drawn areas' stored bounding boxes — the same arithmetic FBoroughShape does for
195
+ one, so nothing here parses a path. The pad is a fraction of the union's LONGER side, so a wide
196
+ selection and a tall one get the same visual margin rather than the same number of user units.
197
+ */
198
+ const frame = computed<readonly number[]>(() => {
199
+ if (!fitted.value) return set.value.viewBox;
200
+ const areas = drawn.value.map((c) => c.area);
201
+ const x0 = Math.min(...areas.map((a) => a.x));
202
+ const y0 = Math.min(...areas.map((a) => a.y));
203
+ const x1 = Math.max(...areas.map((a) => a.x + a.w));
204
+ const y1 = Math.max(...areas.map((a) => a.y + a.h));
205
+ const pad = Math.max(x1 - x0, y1 - y0) * FIT_PAD;
206
+ return [x0 - pad, y0 - pad, x1 - x0 + pad * 2, y1 - y0 + pad * 2];
207
+ });
208
+
209
+ // Unlike the dotted map, empty here IS a third outcome and stays that way. A borough with no row is
210
+ // a borough nobody measured — the areas are given by the geography, not by the data, so absence is
211
+ // silence rather than zero. The dotted map inverts this because its coverage is complete by
212
+ // construction; a choropleth's is not.
213
+ const spaced = (v: number) =>
214
+ props.scale === 'sqrt' ? Math.sqrt(v) : props.scale === 'log' ? Math.log1p(v) : v;
215
+
216
+ const scale = useCellScale<AreaDatum>({
217
+ mode: props.mode,
218
+ states: computed(() => props.states),
219
+ ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
220
+ domain: computed(() =>
221
+ props.domain ? ([spaced(props.domain[0]), spaced(props.domain[1])] as [number, number]) : undefined,
222
+ ),
223
+ steps: computed(() => props.steps),
224
+ data: cells,
225
+ value: (c) => {
226
+ const v = c.datum === undefined ? undefined : props.value?.(c.datum);
227
+ return typeof v === 'number' && Number.isFinite(v) ? spaced(v) : undefined;
228
+ },
229
+ state: (c) => (c.datum === undefined ? undefined : props.state?.(c.datum)),
230
+ });
231
+
232
+ const hasEmpty = computed(() => cells.value.some((c) => scale.isEmpty(c)));
233
+
234
+ // ---- interaction ----
235
+
236
+ const selectedSet = computed(() => new Set(props.selected ?? []));
237
+ const toggle = (code: string) => {
238
+ if (!props.selectable) return;
239
+ const next = new Set(selectedSet.value);
240
+ if (next.has(code)) next.delete(code);
241
+ else next.add(code);
242
+ emit('update:selected', [...next]);
243
+ };
244
+
245
+ // One UTooltip for the map, re-anchored to the hovered shape through `reference`. Anchored to the
246
+ // shape's box rather than the pointer: an area is large enough that a bubble following the cursor
247
+ // wanders far from what it describes.
248
+ const tip = ref<{ text: string; el: Element | null }>({ text: '', el: null });
249
+ const tipOpen = ref(false);
250
+ const showTip = (event: Event, cell: AreaDatum) => {
251
+ if (!props.tooltip) return;
252
+ tip.value = { text: props.tooltip(cell.area, cell.datum), el: event.currentTarget as Element };
253
+ tipOpen.value = true;
254
+ };
255
+ const hideTip = () => {
256
+ tipOpen.value = false;
257
+ };
258
+
259
+ const mapLabel = computed(() => {
260
+ if (props.ariaLabel) return props.ariaLabel;
261
+ const named = cells.value.filter((c) => !scale.isEmpty(c)).length;
262
+ const noun = props.level === 'ward' ? 'wards' : 'boroughs';
263
+ return `London by ${noun}, shaded by value. ${named} of ${cells.value.length} ${noun} carry a reading.`;
264
+ });
265
+
266
+ const labelFor = (cell: AreaDatum) =>
267
+ props.tooltip?.(cell.area, cell.datum) ?? `${cell.area.name}, no data`;
268
+
269
+ // Labels are placed largest-area-first, and one is dropped when it would collide.
270
+ //
271
+ // Fitting each label to its own shape is not enough. The inner boroughs are the smallest and the
272
+ // longest-named — Hammersmith and Fulham, Kensington and Chelsea and the City all meet inside a few
273
+ // hundred pixels — and even where a shape has room by its own bounding box, its neighbour's label
274
+ // reaches across and overlaps it. So the test is against the labels already placed, not against the
275
+ // geometry: work down from the biggest area, keep a label only where its box is still clear, and let
276
+ // the tooltip name whatever had to be dropped.
277
+ const LABEL_EM = 9;
278
+ /** The size labels actually render at, in viewBox units — the size the boxes have to reserve for. */
279
+ const labelFont = computed(() => strokeScale.value * LABEL_EM);
280
+
281
+ const labelBox = (area: LondonArea, font: number) => {
282
+ // Average glyph advance for this typeface at small sizes. Close enough to reserve the right room
283
+ // without measuring text, which would mean a layout pass per label.
284
+ const halfW = (area.name.length * font * 0.55) / 2;
285
+ const halfH = font * 0.75;
286
+ return { x0: area.cx - halfW, x1: area.cx + halfW, y0: area.cy - halfH, y1: area.cy + halfH };
287
+ };
288
+
289
+ const labelled = computed<AreaDatum[]>(() => {
290
+ if (!showLabels.value) return [];
291
+ const placed: ReturnType<typeof labelBox>[] = [];
292
+ const out: AreaDatum[] = [];
293
+ const font = labelFont.value;
294
+ // Biggest first, so when two labels compete the one with more map behind it wins.
295
+ const order = [...cells.value].sort((a, b) => b.area.w * b.area.h - a.area.w * a.area.h);
296
+ for (const cell of order) {
297
+ const box = labelBox(cell.area, font);
298
+ if (placed.some((p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0)) continue;
299
+ placed.push(box);
300
+ out.push(cell);
301
+ }
302
+ return out;
303
+ });
304
+ </script>
305
+
306
+ <template>
307
+ <div class="flex w-full flex-col gap-3">
308
+ <div class="overflow-x-auto">
309
+ <!-- max-width and height are inline rather than utilities: Tailwind does not scan this
310
+ package, so a class used only here is never generated and the rule silently does
311
+ nothing. width and height stay attributes for the intrinsic size, and height:auto is
312
+ what keeps the aspect once max-width takes over in a container narrower than the map. -->
313
+ <UTooltip :text="tip.text" :reference="tip.el" :open="tipOpen">
314
+ <svg
315
+ :width="width"
316
+ :height="height"
317
+ :viewBox="frame.join(' ')"
318
+ role="img"
319
+ :aria-label="mapLabel"
320
+ :aria-busy="loading || undefined"
321
+ class="mx-auto block"
322
+ style="max-width: 100%; height: auto"
323
+ >
324
+ <!-- A `g` rather than a shape: the wrapper has to hold the path, and nesting a path inside a
325
+ path is invalid SVG that renders nothing. -->
326
+ <g
327
+ v-for="cell in drawn"
328
+ :key="cell.area.code"
329
+ :tabindex="focusable ? 0 : undefined"
330
+ :role="focusable ? (selectable ? 'button' : 'img') : undefined"
331
+ :aria-label="focusable ? labelFor(cell) : undefined"
332
+ :aria-pressed="selectable ? selectedSet.has(cell.area.code) : undefined"
333
+ :aria-hidden="focusable ? undefined : 'true'"
334
+ class="outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ui-primary)]"
335
+ @mouseenter="showTip($event, cell)"
336
+ @mouseleave="hideTip"
337
+ @focus="showTip($event, cell)"
338
+ @blur="hideTip"
339
+ @click="toggle(cell.area.code)"
340
+ @keydown.enter.prevent="toggle(cell.area.code)"
341
+ @keydown.space.prevent="toggle(cell.area.code)"
342
+ >
343
+ <path
344
+ :d="cell.area.d"
345
+ :fill="scale.colorOf(cell)"
346
+ :stroke-width="strokeScale"
347
+ :class="[
348
+ 'transition-[filter] duration-150',
349
+ interactive ? 'cursor-pointer hover:brightness-110' : '',
350
+ ]"
351
+ :style="{
352
+ stroke: selectedSet.has(cell.area.code) ? 'var(--ui-primary)' : 'var(--ui-text-muted)',
353
+ strokeWidth: selectedSet.has(cell.area.code) ? strokeScale * 3 : strokeScale,
354
+ }"
355
+ />
356
+ </g>
357
+
358
+ <!-- Borough boundaries over the wards, so a ward map still reads as grouped. Drawn after the
359
+ fills and taking no pointer events, so it never steals a hover from the area beneath. -->
360
+ <path
361
+ v-if="showBoroughOutline"
362
+ class="pointer-events-none"
363
+ :d="LONDON_BOROUGHS.areas.map((a) => a.d).join('')"
364
+ fill="none"
365
+ :stroke-width="strokeScale * 1.6"
366
+ style="stroke: var(--ui-bg-inverted)"
367
+ />
368
+
369
+ <!-- Labels last so nothing paints over them, and out of the pointer's way. -->
370
+ <g class="pointer-events-none">
371
+ <text
372
+ v-for="cell in labelled"
373
+ :key="`t-${cell.area.code}`"
374
+ :x="cell.area.cx"
375
+ :y="cell.area.cy"
376
+ text-anchor="middle"
377
+ dominant-baseline="middle"
378
+ :font-size="labelFont"
379
+ fill="var(--ui-text-highlighted)"
380
+ :stroke-width="strokeScale * 2.5"
381
+ stroke="var(--ui-bg)"
382
+ paint-order="stroke"
383
+ >{{ cell.area.name }}</text>
384
+ </g>
385
+ </svg>
386
+ </UTooltip>
387
+ </div>
388
+
389
+ <!-- Centred with the map above it. The legend component itself is shared with the track and the
390
+ calendar, which are left-aligned, so the alignment belongs here rather than in it. -->
391
+ <div v-if="showLegend" class="flex justify-center">
392
+ <FCellLegend
393
+ :legend="scale.legend.value"
394
+ :show-empty="hasEmpty"
395
+ :empty-color="CELL_EMPTY_COLOR"
396
+ />
397
+ </div>
398
+ </div>
399
+ </template>
@@ -0,0 +1,198 @@
1
+ <script setup lang="ts">
2
+ // A word-sized graphic: the shape of a series, with no axes, no legend, no tooltip and no gridlines.
3
+ // It answers "which way, and how steadily", never "what was Tuesday" — anything that needs a reading
4
+ // is a chart, and FChartFrame is the component for that.
5
+ //
6
+ // Deliberately not built on FChartFrame: the frame's whole job is the ceremony a sparkline has none
7
+ // of. It goes straight to VisXYContainer with zero margin, so the line touches its own edges and the
8
+ // graphic is all data.
9
+ //
10
+ // Height is typographic, not perceptual. No study has measured "is this sparkline going up or down"
11
+ // on a multi-segment line — the slope-ratio work everyone cites measured isolated segment PAIRS —
12
+ // so the default follows Tufte's word-sized graphic and is set to sit with a line of text rather
13
+ // than from any optimal-slope claim.
14
+ import { computed, watchEffect } from 'vue';
15
+ import { VisXYContainer, VisLine, VisArea } from '@unovis/vue';
16
+ // CurveType members carry the same string values ('monotoneX', 'linear'); TypeScript will not accept
17
+ // the bare literals for a string enum, so use the members.
18
+ import { CurveType } from '@unovis/ts';
19
+ import { motionDuration } from './motionDuration';
20
+ import { useThemeVersion } from './useThemeVersion';
21
+
22
+ const props = withDefaults(
23
+ defineProps<{
24
+ /** The series. Index is x; a gap is a null. */
25
+ data: (number | null)[];
26
+ /**
27
+ * The same measure over the period before, drawn as a dashed line behind the series. Both are
28
+ * plotted on ONE shared scale — a comparison drawn on its own scale would put two unrelated
29
+ * shapes in the same box and invite the reader to compare their heights.
30
+ *
31
+ * Aligned by index, not by date: give it the same number of points as `data`, so day one of
32
+ * this period sits above day one of the last. A shorter run simply stops early.
33
+ */
34
+ compare?: (number | null)[];
35
+ /** Any CSS colour. Defaults to the current text colour, so it inherits the tile's intent. */
36
+ color?: string;
37
+ /** The comparison line's colour. Defaults to the series colour at low opacity. */
38
+ compareColor?: string;
39
+ height?: number;
40
+ /** Fills under the line at low opacity. Off by default — a plain line is the quieter default. */
41
+ area?: boolean;
42
+ /** Marks the last point, so the series has an end rather than running off the edge. */
43
+ endpoint?: boolean;
44
+ /**
45
+ * Monotone interpolation, on by default. Monotone rather than a plain spline: it stays inside
46
+ * the data's own range, so no peak appears between two points that neither of them reached.
47
+ * Off draws straight segments instead, making every reading a corner. Both settings
48
+ * interpolate — neither draws a staircase, and no step curve is exposed. Line mark only.
49
+ */
50
+ curve?: boolean;
51
+ /**
52
+ * `line` reads a continuous measure; `bar` reads counts per period.
53
+ *
54
+ * The distinction is what the mark ASSERTS between readings. A line — curved or straight —
55
+ * always interpolates, which claims the measure existed at every point along it. That is true
56
+ * of a rate and false of a count: nothing happened "between" Tuesday and Wednesday, and drawing
57
+ * a path across the gap invents it. Bars claim nothing between buckets, which is why discrete
58
+ * counts belong to them.
59
+ *
60
+ * `compare`, `area`, `endpoint` and `curve` are line-only; a dev-mode warning fires if one is
61
+ * passed alongside `bar` rather than being silently dropped.
62
+ */
63
+ mark?: 'line' | 'bar';
64
+ }>(),
65
+ { height: 32, color: 'currentColor', area: false, endpoint: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
66
+ );
67
+
68
+ if (import.meta.env.DEV) {
69
+ watchEffect(() => {
70
+ if (props.mark !== 'bar') return;
71
+ const ignored = (['compare', 'area', 'endpoint'] as const).filter((k) => props[k]);
72
+ if (ignored.length)
73
+ console.warn(`[FSparkline] \`${ignored.join('`, `')}\` ${ignored.length > 1 ? 'are' : 'is'} line-only and ignored under mark="bar".`);
74
+ });
75
+ }
76
+
77
+ // A theme flip changes what currentColor resolves to, and Unovis caches colours on mount.
78
+ const themeVersion = useThemeVersion();
79
+
80
+ // One row per index carrying both series, so Unovis puts them on a single shared y scale.
81
+ // Every accessor takes the whole Point: Unovis infers its Datum generic from the first accessor it
82
+ // sees, so narrowing each one to just the field it reads would pin Datum to that shape and make the
83
+ // others unassignable.
84
+ type Point = { i: number; v: number | null; c: number | null };
85
+ const points = computed<Point[]>(() =>
86
+ props.data.map((v, i) => ({ i, v, c: props.compare?.[i] ?? null })),
87
+ );
88
+ const x = (d: Point) => d.i;
89
+ const y = (d: Point) => d.v;
90
+ const yCompare = (d: Point) => d.c;
91
+
92
+ const hasCompare = computed(() => !!props.compare?.some((v) => v !== null && v !== undefined));
93
+
94
+ // Bars are measured from zero, not from the data's own floor. The line mark frames min-to-max
95
+ // because it reads as a shape; a bar reads as a LENGTH, and a bar that starts anywhere but zero has
96
+ // a length no longer proportional to its value. The domain therefore always contains zero, which
97
+ // also puts negative readings below the baseline instead of quietly flipping them.
98
+ const barDomain = computed(() => {
99
+ const vals = props.data.filter((v): v is number => v !== null);
100
+ const lo = Math.min(0, ...vals);
101
+ const hi = Math.max(0, ...vals);
102
+ return { lo, span: hi - lo || 1 };
103
+ });
104
+
105
+ /** Bottom offset and height as percentages, or null for a gap. */
106
+ const barFor = (v: number | null) => {
107
+ if (v === null) return null;
108
+ const { lo, span } = barDomain.value;
109
+ const zero = (0 - lo) / span;
110
+ const value = (v - lo) / span;
111
+ const from = Math.min(zero, value);
112
+ const to = Math.max(zero, value);
113
+ return {
114
+ bottom: `${from * 100}%`,
115
+ // A reading of zero keeps a hairline. Zero is a real measurement and a gap is the absence of
116
+ // one; collapsing the bar to nothing would render the two identically.
117
+ height: `max(${(to - from) * 100}%, 1px)`,
118
+ // The cap goes on the free end, so a bar below the baseline rounds downward rather than into
119
+ // the axis it grows from.
120
+ negative: v < 0,
121
+ };
122
+ };
123
+
124
+ const last = computed(() => {
125
+ for (let i = props.data.length - 1; i >= 0; i--) if (props.data[i] !== null) return props.data[i] as number;
126
+ return null;
127
+ });
128
+ const bounds = computed(() => {
129
+ const vals = props.data.filter((v): v is number => v !== null);
130
+ return { min: Math.min(...vals), max: Math.max(...vals) };
131
+ });
132
+ // Fraction of the height the final point sits at, for the endpoint dot.
133
+ const endpointTop = computed(() => {
134
+ const { min, max } = bounds.value;
135
+ if (last.value === null || max === min) return 50;
136
+ return (1 - (last.value - min) / (max - min)) * 100;
137
+ });
138
+ </script>
139
+
140
+ <template>
141
+ <div class="relative w-full" :style="{ height: `${height}px`, color }">
142
+ <!-- Plain DOM rather than a mark: there are no axes, scales or tooltip to share with the XY
143
+ container, so a flex row of columns is the whole implementation and costs no chart runtime.
144
+ The 2px gap is fixed, so the bucket count sets how thin the bars get: n buckets across w
145
+ pixels leaves (w - 2(n - 1)) / n each. A tile 280px wide holds 12 buckets at 21px and 60 at
146
+ 2.7px — still bars; past roughly 60 the gap outweighs the bar and the row reads as a
147
+ texture, where the measure belongs to a line instead. -->
148
+ <div v-if="mark === 'bar'" class="flex h-full w-full items-end gap-0.5" aria-hidden="true">
149
+ <span
150
+ v-for="(v, i) in data"
151
+ :key="i"
152
+ class="relative h-full min-w-0 flex-1"
153
+ >
154
+ <span
155
+ v-if="barFor(v)"
156
+ class="absolute inset-x-0 bg-current"
157
+ :class="barFor(v)!.negative ? 'rounded-b-[3px]' : 'rounded-t-[3px]'"
158
+ :style="{ bottom: barFor(v)!.bottom, height: barFor(v)!.height }"
159
+ />
160
+ </span>
161
+ </div>
162
+
163
+ <!-- No bottom margin: the graphic sits on the tile's own bottom edge, and 2px of clearance
164
+ there reads as a misaligned card rather than as breathing room. The top keeps its 2px so a
165
+ peak is not clipped by the stroke's own width, and the right reserves room for the endpoint
166
+ dot when there is one. -->
167
+ <VisXYContainer
168
+ v-else
169
+ :key="themeVersion"
170
+ :data="points"
171
+ :height="height"
172
+ :margin="{ top: 2, right: endpoint ? 4 : 0, bottom: 0, left: 0 }"
173
+ :duration="motionDuration('slow')"
174
+ >
175
+ <!-- Comparison first, so the current series draws over it. Dashed and thinner: the dash says
176
+ "not now" without needing a legend, and holding the same hue keeps the pair readable for a
177
+ colour-blind reader, who would lose a hue-only distinction at this size. -->
178
+ <VisLine
179
+ v-if="hasCompare"
180
+ :x="x"
181
+ :y="yCompare"
182
+ :color="compareColor ?? color"
183
+ :line-width="1"
184
+ :line-dash-array="[2, 2]"
185
+ :opacity="0.45"
186
+ :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear"
187
+ />
188
+ <VisArea v-if="area" :x="x" :y="y" :color="color" :opacity="0.12" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
189
+ <VisLine :x="x" :y="y" :color="color" :line-width="1.5" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
190
+ </VisXYContainer>
191
+ <span
192
+ v-if="mark === 'line' && endpoint && last !== null"
193
+ class="pointer-events-none absolute right-0 size-1.5 -translate-y-1/2 rounded-full bg-current"
194
+ :style="{ top: `${endpointTop}%` }"
195
+ aria-hidden="true"
196
+ />
197
+ </div>
198
+ </template>