@sjvair/map-sdk 0.1.0-beta.1 → 0.1.0-beta.10

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 (52) hide show
  1. package/README.md +243 -17
  2. package/dist/core/create-map.svelte.d.ts +72 -5
  3. package/dist/core/create-map.svelte.js +229 -14
  4. package/dist/core/gl.d.ts +5 -0
  5. package/dist/core/interactions.svelte.d.ts +11 -3
  6. package/dist/core/interactions.svelte.js +128 -11
  7. package/dist/core/plugin-host.svelte.d.ts +30 -5
  8. package/dist/core/plugin-host.svelte.js +93 -14
  9. package/dist/core/plugin.d.ts +44 -2
  10. package/dist/core/shape-view.d.ts +27 -0
  11. package/dist/core/shape-view.js +323 -0
  12. package/dist/data/format.d.ts +1 -1
  13. package/dist/data/format.js +16 -3
  14. package/dist/data/messages/en.json +12 -2
  15. package/dist/data/selection.d.ts +6 -0
  16. package/dist/data/selection.js +21 -0
  17. package/dist/data/types.d.ts +5 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +1 -0
  20. package/dist/plugins/points.svelte.d.ts +14 -2
  21. package/dist/plugins/points.svelte.js +34 -5
  22. package/dist/plugins/shapes.svelte.d.ts +5 -0
  23. package/dist/plugins/shapes.svelte.js +20 -2
  24. package/dist/presets/FireTooltip.svelte +70 -0
  25. package/dist/presets/FireTooltip.svelte.d.ts +13 -0
  26. package/dist/presets/MonitorClusterTooltip.svelte +21 -2
  27. package/dist/presets/cluster-area.d.ts +13 -0
  28. package/dist/presets/cluster-area.js +34 -0
  29. package/dist/presets/hms-fire.d.ts +11 -7
  30. package/dist/presets/hms-fire.js +16 -17
  31. package/dist/presets/hms-smoke.d.ts +2 -1
  32. package/dist/presets/hms-smoke.js +4 -2
  33. package/dist/stores/hms-stores.svelte.d.ts +6 -3
  34. package/dist/stores/hms-stores.svelte.js +4 -4
  35. package/dist/ui/FeatureList.svelte +17 -17
  36. package/dist/ui/Legend.svelte +58 -16
  37. package/dist/ui/Legend.svelte.d.ts +9 -0
  38. package/dist/ui/MapTooltip.svelte +253 -14
  39. package/dist/ui/MapTooltip.svelte.d.ts +9 -0
  40. package/dist/ui/PickNotice.svelte +56 -0
  41. package/dist/ui/PickNotice.svelte.d.ts +7 -0
  42. package/dist/ui/TooltipItem.svelte +12 -12
  43. package/dist/ui/card.d.ts +29 -0
  44. package/dist/ui/card.js +1 -0
  45. package/dist/ui/feature-list.d.ts +17 -2
  46. package/dist/ui/feature-list.js +45 -10
  47. package/dist/ui/value-format.d.ts +3 -2
  48. package/dist/ui/value-format.js +9 -2
  49. package/dist/view/MapView.svelte +72 -2
  50. package/dist/view/MapView.svelte.d.ts +8 -0
  51. package/dist/view/create-gl-map.js +4 -0
  52. package/package.json +2 -1
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ import type { FeatureListItem } from "../core/plugin.js";
3
+ import type { FormatProvider } from "../data/format.js";
4
+ import type { MessageProvider } from "../data/messages.js";
5
+ import { NO_LEVEL, numericValue } from "../data/scale.js";
6
+ import { formatValue } from "../ui/value-format.js";
7
+ import MonitorReadingBox from "./MonitorReadingBox.svelte";
8
+
9
+ // A fire's power in the monitors' reading box, beside its name and level. The value is cut
10
+ // off, not rounded, so it never reads as the next level (9.96 MW shows 9.9 beside "<10").
11
+ // No detection time: a satellite pass isn't when the fire started or whether it still burns.
12
+
13
+ interface Props {
14
+ item: FeatureListItem;
15
+ /** How many detections a group averages; null for a single detection. */
16
+ count: number | null;
17
+ messages: MessageProvider;
18
+ format: FormatProvider;
19
+ }
20
+
21
+ let { item, count, messages, format }: Props = $props();
22
+
23
+ const level = $derived(item.level !== NO_LEVEL ? item.level : null);
24
+ const value = $derived.by(() => {
25
+ const n = numericValue(item.value);
26
+ return n === null ? null : formatValue(n, item.precision, format, true);
27
+ });
28
+ const header = $derived(
29
+ item.units
30
+ ? messages("mapsdk_hms_fire_box_header", { units: item.units })
31
+ : messages("mapsdk_hms_fire_box_header_no_units")
32
+ );
33
+ </script>
34
+
35
+ <div class="mapsdk-fire-tip mapsdk-tooltip-wide">
36
+ <MonitorReadingBox
37
+ color={level?.color || null}
38
+ {header}
39
+ value={value ?? messages("mapsdk_no_reading")}
40
+ large={value !== null}
41
+ subheading={count !== null && count >= 2 ? messages("mapsdk_hms_fire_average") : undefined}
42
+ />
43
+ <div class="mapsdk-fire-detail">
44
+ <span class="mapsdk-fire-name text-lg">{item.name}</span>
45
+ <span>
46
+ {level
47
+ ? item.units
48
+ ? messages("mapsdk_level_with_units", { level: level.label, units: item.units })
49
+ : level.label
50
+ : messages("mapsdk_no_level")}
51
+ </span>
52
+ </div>
53
+ </div>
54
+
55
+ <style>
56
+ .mapsdk-fire-tip {
57
+ display: flex;
58
+ gap: 0.5rem;
59
+ }
60
+
61
+ .mapsdk-fire-name {
62
+ font-weight: 700;
63
+ }
64
+
65
+ .mapsdk-fire-detail {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 0.25rem;
69
+ }
70
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { FeatureListItem } from "../core/plugin.js";
2
+ import type { FormatProvider } from "../data/format.js";
3
+ import type { MessageProvider } from "../data/messages.js";
4
+ interface Props {
5
+ item: FeatureListItem;
6
+ /** How many detections a group averages; null for a single detection. */
7
+ count: number | null;
8
+ messages: MessageProvider;
9
+ format: FormatProvider;
10
+ }
11
+ declare const FireTooltip: import("svelte").Component<Props, {}, "">;
12
+ type FireTooltip = ReturnType<typeof FireTooltip>;
13
+ export default FireTooltip;
@@ -7,12 +7,14 @@
7
7
  import type { MonitorFeatureProperties } from "../data/types.js";
8
8
  import type { ClusterInfo } from "../plugins/points-clusters.js";
9
9
  import { formatValue } from "../ui/value-format.js";
10
+ import { clusterAreaSqMiles } from "./cluster-area.js";
10
11
  import MonitorReadingBox from "./MonitorReadingBox.svelte";
11
12
 
12
13
  // Ported from monitor-map's MonitorClusterTooltip: the cluster's average in a level-colored
13
14
  // box beside its monitor type, count, and highest and lowest readings. The count and average
14
15
  // come from the bubble itself, so they show at once; the rest fills in from the cluster's
15
16
  // monitors. The average is a whole number and max and min are as reported, as monitor-map's.
17
+ // The count line adds the area the monitors are within (their convex hull) once they load.
16
18
 
17
19
  interface Props {
18
20
  cluster: ClusterInfo<MonitorFeatureProperties>;
@@ -29,6 +31,8 @@
29
31
  isSjvair: boolean;
30
32
  /** Readings only; monitors without one are left out. */
31
33
  values: number[];
34
+ /** Square miles of the monitors' convex hull, every monitor included. */
35
+ area: number;
32
36
  }
33
37
 
34
38
  let leaves = $state.raw<Leaves | null>(null);
@@ -60,7 +64,8 @@
60
64
  values: found.flatMap(({ props }) => {
61
65
  const n = numericValue(props.value);
62
66
  return n === null ? [] : [n];
63
- })
67
+ }),
68
+ area: clusterAreaSqMiles(found.map(({ lngLat }) => lngLat))
64
69
  };
65
70
  },
66
71
  () => {
@@ -87,6 +92,16 @@
87
92
  return found !== NO_LEVEL ? found : null;
88
93
  });
89
94
  const units = $derived(scale?.units ?? "");
95
+ /** The area to 3 decimals, or null when there is none to show (it would read 0.000). */
96
+ const area = $derived.by(() => {
97
+ // Decided on the number, so a host's own number format can't change what's hidden.
98
+ if (!leaves || Math.round(leaves.area * 1000) === 0) return null;
99
+ return format.number(leaves.area, {
100
+ minimumFractionDigits: 3,
101
+ maximumFractionDigits: 3,
102
+ useGrouping: false
103
+ });
104
+ });
90
105
  const max = $derived(leaves?.values.length ? Math.max(...leaves.values) : null);
91
106
  const min = $derived(leaves?.values.length ? Math.min(...leaves.values) : null);
92
107
  </script>
@@ -108,7 +123,11 @@
108
123
  )}
109
124
  </span>
110
125
  {/if}
111
- <span>{messages("mapsdk_monitor_cluster_count", { count: cluster.pointCount })}</span>
126
+ <span>
127
+ {area
128
+ ? messages("mapsdk_monitor_cluster_count_area", { count: cluster.pointCount, area })
129
+ : messages("mapsdk_monitor_cluster_count", { count: cluster.pointCount })}
130
+ </span>
112
131
  <span>{level ? level.label : messages("mapsdk_no_level")}</span>
113
132
  {#if max !== null && min !== null}
114
133
  <span class="mapsdk-monitor-extremes text-base">
@@ -0,0 +1,13 @@
1
+ type Point = [number, number];
2
+ /**
3
+ * The convex hull of lng/lat points (monotone chain): counter-clockwise, without a repeated
4
+ * closing point, inner and collinear points dropped. Fewer than 3 points means no area.
5
+ */
6
+ export declare function convexHull(points: Point[]): Point[];
7
+ /**
8
+ * The area, in square miles, of the smallest shape around a cluster's monitors (their convex
9
+ * hull), as sjvair.com's map computed area: `@turf/area`, then `convertArea`. 0 for fewer than 3
10
+ * points not on one line.
11
+ */
12
+ export declare function clusterAreaSqMiles(points: Point[]): number;
13
+ export {};
@@ -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 {
@@ -9,12 +9,13 @@
9
9
  inView,
10
10
  optionIdPart,
11
11
  rowLabel,
12
+ levelText,
12
13
  shownLevel,
14
+ shownValue,
13
15
  sortItems,
14
16
  typeaheadIndex,
15
17
  type SortBy
16
18
  } from "./feature-list.js";
17
- import { formatValue as formatNumber } from "./value-format.js";
18
19
 
19
20
  interface Props {
20
21
  map: MapContext;
@@ -200,7 +201,8 @@
200
201
  case "Enter":
201
202
  case " ":
202
203
  e.preventDefault();
203
- if (current >= 0) pick(rows[current], e.key === " ");
204
+ // Space, or Shift with Enter, is the additive key (as Shift-click on a row).
205
+ if (current >= 0) pick(rows[current], e.key === " " || e.shiftKey);
204
206
  return;
205
207
  default:
206
208
  if (e.key.length === 1 && e.key !== " ") {
@@ -266,13 +268,6 @@
266
268
  pick(rows[index], e.shiftKey || e.metaKey || e.ctrlKey);
267
269
  }
268
270
 
269
- function formatValue(row: Row): string {
270
- if (row.value === null) return "";
271
- return typeof row.value === "number"
272
- ? formatNumber(row.value, row.precision, map.format)
273
- : row.value;
274
- }
275
-
276
271
  onDestroy(() => {
277
272
  clearTimeout(typeaheadTimer);
278
273
  clearTimeout(menuSentTimer);
@@ -330,6 +325,8 @@
330
325
  {#each windowRows as row, i (row.key)}
331
326
  {@const index = range.start + i}
332
327
  {@const level = shownLevel(row)}
328
+ {@const levelPlace = levelText(row, map.messages)}
329
+ {@const value = shownValue(row, map.format)}
333
330
  {@const isOn = isSelected(row)}
334
331
  <!-- Options aren't focused: the listbox holds focus and handles keys (aria-activedescendant). -->
335
332
  <!-- svelte-ignore a11y_click_events_have_key_events, a11y_interactive_supports_focus -->
@@ -357,15 +354,17 @@
357
354
  </span>
358
355
  <span class="mapsdk-list-name">{row.name}</span>
359
356
  <span class="mapsdk-list-value">
360
- {formatValue(row)}{#if row.units && row.value !== null}&nbsp;{row.units}{/if}
357
+ {value ?? ""}{#if row.units && value !== null}&nbsp;{row.units}{/if}
361
358
  </span>
362
359
  <span class="mapsdk-list-level text-xs">
363
- <span
364
- class="mapsdk-list-swatch"
365
- class:neutral={!level?.color}
366
- style:background={level?.color || undefined}
367
- ></span>
368
- {level ? level.label : map.messages("mapsdk_no_level")}
360
+ {#if levelPlace.kind !== "detail"}
361
+ <span
362
+ class="mapsdk-list-swatch"
363
+ class:neutral={!level?.color}
364
+ style:background={level?.color || undefined}
365
+ ></span>
366
+ {/if}
367
+ {levelPlace.text}
369
368
  </span>
370
369
  </div>
371
370
  {/each}
@@ -383,7 +382,8 @@
383
382
  flex-direction: column;
384
383
  position: relative;
385
384
  width: 21rem;
386
- max-width: calc(100vw - 2rem);
385
+ /* The map's width inside a MapView (set there), else the window's. */
386
+ max-width: calc(var(--mapsdk-map-width, 100vw) - 2rem);
387
387
  max-height: 100%;
388
388
  box-sizing: border-box;
389
389
  border: 1px solid var(--card-border, var(--border));
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount } from "svelte";
2
+ import { onMount, untrack } from "svelte";
3
3
  import "./controls.css";
4
4
  import type { MapContext } from "../core/create-map.svelte.js";
5
5
  import { watchTheme } from "../view/create-gl-map.js";
@@ -8,9 +8,19 @@
8
8
 
9
9
  let {
10
10
  map,
11
- order = "bottom-first"
11
+ order = "bottom-first",
12
+ framed = true
12
13
  }: {
13
14
  map: MapContext;
15
+ /**
16
+ * `true` (default) draws the legend's own card. `false` draws no frame (no border,
17
+ * background, padding, or fixed width) so a host's card frames it. Translucent swatches
18
+ * then blend over the nearest ancestor with an opaque background (a see-through card is
19
+ * skipped), else the `--background` token. The color is read on mount, when `framed`
20
+ * changes, and on a theme change; a card that changes color through its own class or
21
+ * style isn't re-read.
22
+ */
23
+ framed?: boolean;
14
24
  /**
15
25
  * `"bottom-first"` (default) lists the bottom plugin's section first; `"top-first"`
16
26
  * last.
@@ -43,30 +53,50 @@
43
53
  )
44
54
  );
45
55
 
46
- /** Computed colors can be oklch() or color(); a 1x1 canvas turns any of them into rgb. */
47
- function resolveBackground(el: Element): string {
48
- const css = getComputedStyle(el).backgroundColor;
56
+ /**
57
+ * A computed color as rgb and whether it's opaque; computed colors can be oklch() or color(),
58
+ * and a 1x1 canvas turns any of them into rgb.
59
+ */
60
+ function toRgb(css: string): { rgb: string; opaque: boolean } {
49
61
  const ctx = document.createElement("canvas").getContext("2d");
50
- if (!ctx) return css;
62
+ if (!ctx) return { rgb: css, opaque: true };
51
63
  ctx.clearRect(0, 0, 1, 1);
52
64
  ctx.fillStyle = css;
53
65
  ctx.fillRect(0, 0, 1, 1);
54
- const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
55
- return `rgb(${r}, ${g}, ${b})`;
66
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
67
+ return { rgb: `rgb(${r}, ${g}, ${b})`, opaque: a === 255 };
56
68
  }
57
69
 
58
- onMount(() => {
59
- const update = () => {
60
- if (root) background = resolveBackground(root);
61
- };
62
- update();
63
- return watchTheme(root!, update);
70
+ /**
71
+ * What translucent swatches blend over: the legend's own card, or without a frame the nearest
72
+ * ancestor with an opaque background (the host's card), else the `--background` token.
73
+ */
74
+ function resolveBackground(el: HTMLElement, frameless: boolean): string {
75
+ if (!frameless) return toRgb(getComputedStyle(el).backgroundColor).rgb;
76
+ for (let at = el.parentElement; at; at = at.parentElement) {
77
+ const color = toRgb(getComputedStyle(at).backgroundColor);
78
+ if (color.opaque) return color.rgb;
79
+ }
80
+ // A computed custom property has its var() references already substituted.
81
+ return toRgb(getComputedStyle(el).getPropertyValue("--background").trim()).rgb;
82
+ }
83
+
84
+ function update() {
85
+ if (root) background = resolveBackground(root, !framed);
86
+ }
87
+
88
+ onMount(() => watchTheme(root!, update));
89
+
90
+ // On mount, and again when `framed` changes.
91
+ $effect(() => {
92
+ void framed;
93
+ untrack(update);
64
94
  });
65
95
  </script>
66
96
 
67
97
  <section
68
98
  bind:this={root}
69
- class="mapsdk-legend"
99
+ class={["mapsdk-legend", !framed && "mapsdk-legend-frameless"]}
70
100
  aria-label={map.messages("mapsdk_legend_label")}
71
101
  hidden={entries.length === 0}
72
102
  >
@@ -113,7 +143,8 @@
113
143
  flex-direction: column;
114
144
  gap: 0.75rem;
115
145
  width: 21rem;
116
- max-width: calc(100vw - 2rem);
146
+ /* The map's width inside a MapView (set there), else the window's. */
147
+ max-width: calc(var(--mapsdk-map-width, 100vw) - 2rem);
117
148
  box-sizing: border-box;
118
149
  padding: 0.75rem 1rem;
119
150
  border: 1px solid var(--card-border, var(--border));
@@ -124,6 +155,17 @@
124
155
  pointer-events: auto;
125
156
  }
126
157
 
158
+ /* framed={false}: the host's card is the frame. */
159
+ .mapsdk-legend-frameless {
160
+ width: auto;
161
+ max-width: none;
162
+ padding: 0;
163
+ border: 0;
164
+ border-radius: 0;
165
+ background: none;
166
+ box-shadow: none;
167
+ }
168
+
127
169
  .mapsdk-legend[hidden] {
128
170
  display: none;
129
171
  }
@@ -2,6 +2,15 @@ import "./controls.css";
2
2
  import type { MapContext } from "../core/create-map.svelte.js";
3
3
  type $$ComponentProps = {
4
4
  map: MapContext;
5
+ /**
6
+ * `true` (default) draws the legend's own card. `false` draws no frame (no border,
7
+ * background, padding, or fixed width) so a host's card frames it. Translucent swatches
8
+ * then blend over the nearest ancestor with an opaque background (a see-through card is
9
+ * skipped), else the `--background` token. The color is read on mount, when `framed`
10
+ * changes, and on a theme change; a card that changes color through its own class or
11
+ * style isn't re-read.
12
+ */
13
+ framed?: boolean;
5
14
  /**
6
15
  * `"bottom-first"` (default) lists the bottom plugin's section first; `"top-first"`
7
16
  * last.