@sjvair/map-sdk 0.1.0-beta.3 → 0.1.0-beta.5

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 (38) hide show
  1. package/README.md +99 -5
  2. package/dist/core/create-map.svelte.d.ts +17 -2
  3. package/dist/core/create-map.svelte.js +108 -5
  4. package/dist/core/interactions.svelte.d.ts +5 -3
  5. package/dist/core/interactions.svelte.js +20 -7
  6. package/dist/core/plugin-host.svelte.js +2 -2
  7. package/dist/core/plugin.d.ts +25 -0
  8. package/dist/core/shape-view.d.ts +27 -0
  9. package/dist/core/shape-view.js +323 -0
  10. package/dist/data/format.d.ts +1 -1
  11. package/dist/data/format.js +16 -3
  12. package/dist/data/messages/en.json +8 -1
  13. package/dist/data/types.d.ts +5 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +1 -0
  16. package/dist/plugins/points.svelte.d.ts +5 -0
  17. package/dist/plugins/points.svelte.js +2 -1
  18. package/dist/plugins/shapes.svelte.d.ts +5 -0
  19. package/dist/plugins/shapes.svelte.js +20 -2
  20. package/dist/presets/FireTooltip.svelte +70 -0
  21. package/dist/presets/FireTooltip.svelte.d.ts +13 -0
  22. package/dist/presets/MonitorClusterTooltip.svelte +21 -2
  23. package/dist/presets/cluster-area.d.ts +13 -0
  24. package/dist/presets/cluster-area.js +34 -0
  25. package/dist/presets/hms-fire.d.ts +11 -7
  26. package/dist/presets/hms-fire.js +16 -17
  27. package/dist/presets/hms-smoke.d.ts +2 -1
  28. package/dist/presets/hms-smoke.js +4 -2
  29. package/dist/stores/hms-stores.svelte.d.ts +6 -3
  30. package/dist/stores/hms-stores.svelte.js +4 -4
  31. package/dist/ui/FeatureList.svelte +1 -1
  32. package/dist/ui/MapTooltip.svelte +17 -4
  33. package/dist/ui/TooltipItem.svelte +1 -1
  34. package/dist/ui/feature-list.d.ts +4 -1
  35. package/dist/ui/feature-list.js +9 -5
  36. package/dist/ui/value-format.d.ts +3 -2
  37. package/dist/ui/value-format.js +9 -2
  38. package/package.json +2 -1
@@ -0,0 +1,34 @@
1
+ import area from "@turf/area";
2
+ import { convertArea, polygon } from "@turf/helpers";
3
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
4
+ /**
5
+ * The convex hull of lng/lat points (monotone chain): counter-clockwise, without a repeated
6
+ * closing point, inner and collinear points dropped. Fewer than 3 points means no area.
7
+ */
8
+ export function convexHull(points) {
9
+ const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
10
+ if (sorted.length < 3)
11
+ return sorted;
12
+ const half = (from) => {
13
+ const chain = [];
14
+ for (const p of from) {
15
+ while (chain.length >= 2 && cross(chain[chain.length - 2], chain[chain.length - 1], p) <= 0)
16
+ chain.pop();
17
+ chain.push(p);
18
+ }
19
+ chain.pop();
20
+ return chain;
21
+ };
22
+ return [...half(sorted), ...half([...sorted].reverse())];
23
+ }
24
+ /**
25
+ * The area, in square miles, of the smallest shape around a cluster's monitors (their convex
26
+ * hull), as sjvair.com's map computed area: `@turf/area`, then `convertArea`. 0 for fewer than 3
27
+ * points not on one line.
28
+ */
29
+ export function clusterAreaSqMiles(points) {
30
+ const hull = convexHull(points);
31
+ if (hull.length < 3)
32
+ return 0;
33
+ return convertArea(area(polygon([[...hull, hull[0]]])), "meters", "miles");
34
+ }
@@ -1,12 +1,16 @@
1
1
  import type { MapPlugin } from "../core/plugin.js";
2
- import type { DataSource } from "../data/types.js";
2
+ import type { DataSource, FireFeatureProperties } from "../data/types.js";
3
3
  export interface HmsFirePluginOptions {
4
4
  /** Default "hms-fire". */
5
5
  id?: string;
6
- /** The fires, or with `hotspots` the groups shown below zoom 10 (listed). */
7
- source: DataSource;
6
+ /**
7
+ * The fires, or with `hotspots` the groups shown below zoom 10 (listed). A feature with a
8
+ * `count` is a group (its tooltip says "Average" from 2 detections); one without is a single
9
+ * detection.
10
+ */
11
+ source: DataSource<FireFeatureProperties>;
8
12
  /** Single detections shown from zoom 10 instead of `source` (not listed). Default: none. */
9
- hotspots?: DataSource;
13
+ hotspots?: DataSource<FireFeatureProperties>;
10
14
  /** Already-translated layer name for the list and summary. */
11
15
  layerLabel: string;
12
16
  }
@@ -16,9 +20,9 @@ export interface HmsFirePluginOptions {
16
20
  *
17
21
  * With `hotspots`, it is still one layer (one legend section, one list) that switches by itself
18
22
  * while zooming: `source` (groups from `groupFires`, value = `avgFrp`) below zoom 10, `hotspots`
19
- * (each detection) from zoom 10. Both are hit-tested and show the same tooltip (name, value,
20
- * level);
21
- * only the groups are listed.
23
+ * (each detection) from zoom 10. Both are hit-tested and show the same tooltip: the power in a
24
+ * reading box colored by its level, beside the name and level; only the groups are listed. Values
25
+ * are cut off at the display precision, not rounded, in the tooltip, list, and accessible names.
22
26
  *
23
27
  * The sources' `scale` is expected to be the ScaleRef `{ dataset: "hms-fire", entryType:
24
28
  * "fire-frp" }`, and each feature's `value` its FRP. This preset does not fetch.
@@ -1,7 +1,7 @@
1
1
  import { scopedContext } from "../core/scoped-context.js";
2
2
  import { fireIcon } from "../plugins/icons.js";
3
3
  import { pointsPlugin } from "../plugins/points.svelte.js";
4
- import TooltipItem from "../ui/TooltipItem.svelte";
4
+ import FireTooltip from "./FireTooltip.svelte";
5
5
  import { componentSnippet } from "./monitors.js";
6
6
  /** The zoom where `hotspots` replace the groups (inclusive), as sjvair.com's map does. */
7
7
  const HOTSPOT_ZOOM = 10;
@@ -22,6 +22,14 @@ function firePoints(id, source, layerLabel, zoomRange) {
22
22
  selectable: false,
23
23
  zoomRange,
24
24
  legendDisplay: "blocks",
25
+ truncateValues: true,
26
+ tooltipShowsName: true,
27
+ tooltip: (item, props, env) => componentSnippet(FireTooltip, {
28
+ item,
29
+ count: typeof props.count === "number" ? props.count : null,
30
+ messages: env.messages,
31
+ format: env.format
32
+ }),
25
33
  icon: {
26
34
  category: () => "fire",
27
35
  draw: (_category, color, _theme, levelIndex) => drawFire(color, levelIndex),
@@ -35,9 +43,9 @@ function firePoints(id, source, layerLabel, zoomRange) {
35
43
  *
36
44
  * With `hotspots`, it is still one layer (one legend section, one list) that switches by itself
37
45
  * while zooming: `source` (groups from `groupFires`, value = `avgFrp`) below zoom 10, `hotspots`
38
- * (each detection) from zoom 10. Both are hit-tested and show the same tooltip (name, value,
39
- * level);
40
- * only the groups are listed.
46
+ * (each detection) from zoom 10. Both are hit-tested and show the same tooltip: the power in a
47
+ * reading box colored by its level, beside the name and level; only the groups are listed. Values
48
+ * are cut off at the display precision, not rounded, in the tooltip, list, and accessible names.
41
49
  *
42
50
  * The sources' `scale` is expected to be the ScaleRef `{ dataset: "hms-fire", entryType:
43
51
  * "fire-frp" }`, and each feature's `value` its FRP. This preset does not fetch.
@@ -50,8 +58,6 @@ export function hmsFirePlugin(options) {
50
58
  // Both inner plugins share the outer id, so their hits are this plugin's.
51
59
  const groups = firePoints(id, source, layerLabel, { max: HOTSPOT_ZOOM });
52
60
  const singles = firePoints(id, hotspots, layerLabel, { min: HOTSPOT_ZOOM });
53
- // The providers of the running setup, for the hotspots' tooltips.
54
- let ctxNow = null;
55
61
  return {
56
62
  id,
57
63
  label: layerLabel,
@@ -60,28 +66,21 @@ export function hmsFirePlugin(options) {
60
66
  },
61
67
  selectable: false,
62
68
  setup(ctx) {
63
- ctxNow = ctx;
64
69
  const teardownGroups = groups.setup(scopedContext(ctx, "groups:"));
65
70
  const teardownSingles = singles.setup(scopedContext(ctx, "hotspots:"));
66
71
  return () => {
67
- ctxNow = null;
68
72
  teardownSingles();
69
73
  teardownGroups();
70
74
  };
71
75
  },
72
76
  legend: groups.legend,
73
- // Only the groups are listed; a hovered hotspot shows what a group's hover does: its name
74
- // (the generic header) with its value and level.
77
+ // Only the groups are listed; a hovered hotspot shows the same box as a group.
75
78
  features: groups.features,
79
+ tooltip: groups.tooltip,
80
+ tooltipShowsName: true,
76
81
  hoverTooltip(hit) {
77
82
  const item = singles.features?.().find((f) => f.refKey === hit.refKey);
78
- if (!item || !ctxNow)
79
- return undefined;
80
- return componentSnippet(TooltipItem, {
81
- item,
82
- messages: ctxNow.messages,
83
- format: ctxNow.format
84
- });
83
+ return item && singles.tooltip?.(item);
85
84
  }
86
85
  };
87
86
  }
@@ -10,7 +10,8 @@ export interface HmsSmokePluginOptions {
10
10
  /**
11
11
  * NOAA HMS smoke plumes: polygons filled and outlined (`outline: "level"`) by density, using each
12
12
  * level's opacity. Not selectable; listed (each plume a row with its density level), so plumes
13
- * can be reached without a mouse.
13
+ * can be reached without a mouse. Hovering a plume shows no tooltip and never hides another
14
+ * layer's (`hoverable: false`); focusing one from the list still pins its tooltip.
14
15
  *
15
16
  * The source's `scale` is expected to be the ScaleRef
16
17
  * `{ dataset: "hms-smoke", entryType: "smoke-density" }`, and each feature's `value` its density
@@ -2,7 +2,8 @@ import { shapesPlugin } from "../plugins/shapes.svelte.js";
2
2
  /**
3
3
  * NOAA HMS smoke plumes: polygons filled and outlined (`outline: "level"`) by density, using each
4
4
  * level's opacity. Not selectable; listed (each plume a row with its density level), so plumes
5
- * can be reached without a mouse.
5
+ * can be reached without a mouse. Hovering a plume shows no tooltip and never hides another
6
+ * layer's (`hoverable: false`); focusing one from the list still pins its tooltip.
6
7
  *
7
8
  * The source's `scale` is expected to be the ScaleRef
8
9
  * `{ dataset: "hms-smoke", entryType: "smoke-density" }`, and each feature's `value` its density
@@ -15,6 +16,7 @@ export function hmsSmokePlugin(options) {
15
16
  layerLabel: options.layerLabel,
16
17
  mode: "choropleth",
17
18
  selectable: false,
18
- outline: "level"
19
+ outline: "level",
20
+ hoverable: false
19
21
  });
20
22
  }
@@ -1,5 +1,5 @@
1
1
  import { type MessageProvider } from "../data/messages.js";
2
- import type { DataSource, DataStatus } from "../data/types.js";
2
+ import type { DataSource, DataStatus, FireFeatureProperties } from "../data/types.js";
3
3
  import type { SignalOptions } from "./abort.js";
4
4
  export interface HmsStoreOptions {
5
5
  /** Names features ("Smoke", "Fire detection"). Default: the built-in English messages. */
@@ -22,8 +22,11 @@ export interface HmsSmokeStore {
22
22
  export interface HmsFireStore {
23
23
  /** `loading` until loaded, then `ready` (also when today has no fires) or `error`. */
24
24
  readonly status: DataStatus;
25
- /** Nearby detections merged (`groupFires`), for `hmsFirePlugin`; `value` is the mean FRP. */
26
- readonly groups: DataSource;
25
+ /**
26
+ * Nearby detections merged (`groupFires`), for `hmsFirePlugin`; `value` is the mean FRP and
27
+ * `count` how many detections it merges.
28
+ */
29
+ readonly groups: DataSource<FireFeatureProperties>;
27
30
  /** Each detection (`cleanFires`), for `hmsFirePlugin`; `value` is its FRP. */
28
31
  readonly hotspots: DataSource;
29
32
  /** As `HmsSmokeStore.init`. */
@@ -69,15 +69,15 @@ export function createHmsFireStore(options = {}) {
69
69
  value: fire.frp
70
70
  }
71
71
  }));
72
+ // Every group names its count, a group of 1 included ("1 fire detection").
72
73
  const groups = groupFires(detections).map((group) => ({
73
74
  type: "Feature",
74
75
  geometry: { type: "Point", coordinates: group.coordinates },
75
76
  properties: {
76
77
  refKey: encodeRef({ kind: "feature", id: group.id }),
77
- name: group.count === 1
78
- ? detection
79
- : messages("mapsdk_hms_fire_detections", { count: group.count }),
80
- value: group.avgFrp
78
+ name: messages("mapsdk_hms_fire_detections", { count: group.count }),
79
+ value: group.avgFrp,
80
+ count: group.count
81
81
  }
82
82
  }));
83
83
  return {
@@ -270,7 +270,7 @@
270
270
  function formatValue(row: Row): string {
271
271
  if (row.value === null) return "";
272
272
  return typeof row.value === "number"
273
- ? formatNumber(row.value, row.precision, map.format)
273
+ ? formatNumber(row.value, row.precision, map.format, row.truncate)
274
274
  : row.value;
275
275
  }
276
276
 
@@ -70,11 +70,24 @@
70
70
  // A found item anchors the tooltip at its point (stable while the pointer moves over it).
71
71
  // In pointer mode a hovered hit is anchored at the pointer instead (a focused item stays
72
72
  // pinned).
73
- const anchor = $derived(
74
- mode === "pointer" && !map.focusedKey
75
- ? (map.hovered?.lngLat ?? null)
76
- : (item?.lngLat ?? map.hovered?.lngLat ?? null)
73
+ // A hovered shape whose point is off-screen pins at a point of it in view, found once per
74
+ // hovered feature (keyed on primitives), so pointer moves within it don't move the tooltip.
75
+ // It's found again when the map settles (`bounds`), so a pan or zoom under a resting pointer
76
+ // doesn't leave it off-screen.
77
+ const hoverKey = $derived(
78
+ !map.focusedKey && item && hitPluginId ? `${hitPluginId}\n${item.refKey}` : null
77
79
  );
80
+ const hoverAnchor = $derived.by(() => {
81
+ if (hoverKey === null) return null;
82
+ void map.bounds;
83
+ return untrack(() => (item?.geometry ? map.anchorFor(item) : null));
84
+ });
85
+ const anchor = $derived.by((): [number, number] | null => {
86
+ if (mode === "pointer" && !map.focusedKey) return map.hovered?.lngLat ?? null;
87
+ // A focused feature that left the data has no item, so no tooltip.
88
+ if (map.focusedKey) return item ? (map.focusedAnchor ?? item.lngLat) : null;
89
+ return hoverAnchor ?? item?.lngLat ?? map.hovered?.lngLat ?? null;
90
+ });
78
91
  const projected = $derived(anchor ? project(anchor) : null);
79
92
  // Centered above the point; flipped below it near the top edge; inside the container.
80
93
  const position = $derived.by(() => {
@@ -30,7 +30,7 @@
30
30
  item.value == null
31
31
  ? null
32
32
  : typeof item.value === "number"
33
- ? formatValue(item.value, item.precision, format)
33
+ ? formatValue(item.value, item.precision, format, item.truncate)
34
34
  : item.value
35
35
  );
36
36
  </script>
@@ -16,7 +16,10 @@ export declare const MENU_EVENT_WINDOW_MS = 1000;
16
16
  * separator and different rows never share a fragment.
17
17
  */
18
18
  export declare function optionIdPart(pluginId: string, refKey: string): string;
19
- /** Items whose point is inside `bounds`, edges included. No antimeridian handling (the Valley). */
19
+ /**
20
+ * Items in `bounds`, edges included: a shape (`geometry`) when it overlaps them, anything else when
21
+ * its point is inside. No antimeridian handling (the Valley).
22
+ */
20
23
  export declare function inView(items: FeatureListItem[], bounds: Bounds): FeatureListItem[];
21
24
  /**
22
25
  * The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
@@ -1,3 +1,4 @@
1
+ import { intersectsBounds } from "../core/shape-view.js";
1
2
  import { NO_LEVEL } from "../data/scale.js";
2
3
  import { formatValue } from "./value-format.js";
3
4
  /**
@@ -14,11 +15,12 @@ export function optionIdPart(pluginId, refKey) {
14
15
  const escape = (text) => text.replace(/[^A-Za-z0-9]/g, (c) => `_${c.codePointAt(0).toString(16)}_`);
15
16
  return `${escape(pluginId)}--${escape(refKey)}`;
16
17
  }
17
- /** Items whose point is inside `bounds`, edges included. No antimeridian handling (the Valley). */
18
+ /**
19
+ * Items in `bounds`, edges included: a shape (`geometry`) when it overlaps them, anything else when
20
+ * its point is inside. No antimeridian handling (the Valley).
21
+ */
18
22
  export function inView(items, bounds) {
19
- return items.filter(({ lngLat: [lng, lat] }) => {
20
- return lng >= bounds.west && lng <= bounds.east && lat >= bounds.south && lat <= bounds.north;
21
- });
23
+ return items.filter((item) => intersectsBounds(item, bounds));
22
24
  }
23
25
  /**
24
26
  * The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
@@ -87,7 +89,9 @@ export function rowLabel(item, messages, format) {
87
89
  }
88
90
  const params = {
89
91
  name: item.name,
90
- value: typeof item.value === "number" ? formatValue(item.value, item.precision, format) : item.value,
92
+ value: typeof item.value === "number"
93
+ ? formatValue(item.value, item.precision, format, item.truncate)
94
+ : item.value,
91
95
  units: item.units,
92
96
  level: level.text
93
97
  };
@@ -2,6 +2,7 @@ import type { FormatProvider } from "../data/format.js";
2
2
  /**
3
3
  * A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
4
4
  * there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
5
- * so they all agree.
5
+ * so they all agree. With `truncate` the extra decimals are cut off rather than rounded (9.96 at
6
+ * precision 1 is "9.9"), so the number never reads as the next level up.
6
7
  */
7
- export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider): string;
8
+ export declare function formatValue(value: number, precision: number | undefined, format: FormatProvider, truncate?: boolean): string;
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * A numeric value with exactly `precision` decimal places, or as `format.number` gives it when
3
3
  * there is no precision. Every place that shows a value (list rows, tooltips) goes through this,
4
- * so they all agree.
4
+ * so they all agree. With `truncate` the extra decimals are cut off rather than rounded (9.96 at
5
+ * precision 1 is "9.9"), so the number never reads as the next level up.
5
6
  */
6
- export function formatValue(value, precision, format) {
7
+ export function formatValue(value, precision, format, truncate = false) {
7
8
  if (precision === undefined)
8
9
  return format.number(value);
10
+ if (truncate) {
11
+ const factor = 10 ** precision;
12
+ // toPrecision drops float noise first (4.35 * 100 is 434.99999999999994); 15 digits keep
13
+ // any real value from rounding up.
14
+ value = Math.trunc(Number((value * factor).toPrecision(15))) / factor;
15
+ }
9
16
  return format.number(value, {
10
17
  minimumFractionDigits: precision,
11
18
  maximumFractionDigits: precision
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sjvair/map-sdk",
3
3
  "description": "Svelte 5 map components for SJVAir air-quality data",
4
- "version": "0.1.0-beta.3",
4
+ "version": "0.1.0-beta.5",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -63,6 +63,7 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@maptiler/sdk": "~4.2.0",
66
+ "@turf/area": "^7.4.0",
66
67
  "@turf/clusters-dbscan": "^7.4.0",
67
68
  "@turf/helpers": "^7.4.0",
68
69
  "@types/geojson": "^7946.0.16",