@ruckc/truss-ol 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Map.svelte +271 -0
- package/dist/index.js +1 -0
- package/dist/types.js +1 -0
- package/package.json +51 -5
- package/README.md +0 -3
package/dist/Map.svelte
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, type Snippet } from 'svelte';
|
|
3
|
+
import { Button } from '@ruckc/truss-ui';
|
|
4
|
+
import 'ol/ol.css';
|
|
5
|
+
import type { Basemap, MapMarker, MapSelection } from './types.js';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
center = $bindable([0, 20]),
|
|
9
|
+
zoom = $bindable(2),
|
|
10
|
+
basemap = 'osm',
|
|
11
|
+
markers = [],
|
|
12
|
+
geojson,
|
|
13
|
+
selected = $bindable(),
|
|
14
|
+
height = 420,
|
|
15
|
+
fitOnLoad = true,
|
|
16
|
+
popup,
|
|
17
|
+
toolbar,
|
|
18
|
+
onselect
|
|
19
|
+
}: {
|
|
20
|
+
/** [lon, lat] */
|
|
21
|
+
center?: [number, number];
|
|
22
|
+
zoom?: number;
|
|
23
|
+
basemap?: Basemap;
|
|
24
|
+
markers?: MapMarker[];
|
|
25
|
+
/** GeoJSON FeatureCollection/Feature. Optional `properties.kind` picks the intent color. */
|
|
26
|
+
geojson?: object;
|
|
27
|
+
/** Selected marker id. */
|
|
28
|
+
selected?: string;
|
|
29
|
+
height?: number | string;
|
|
30
|
+
/** Fit the view to markers/features on first load. */
|
|
31
|
+
fitOnLoad?: boolean;
|
|
32
|
+
/** Custom popup body for the selected marker. */
|
|
33
|
+
popup?: Snippet<[marker: MapMarker]>;
|
|
34
|
+
/** Extra controls appended to the toolbar. */
|
|
35
|
+
toolbar?: Snippet;
|
|
36
|
+
onselect?: (s: MapSelection | null) => void;
|
|
37
|
+
} = $props();
|
|
38
|
+
|
|
39
|
+
let el: HTMLDivElement;
|
|
40
|
+
let popupEl: HTMLDivElement;
|
|
41
|
+
let ready = $state(false);
|
|
42
|
+
let ctl: { zoomBy(d: number): void; fit(): void } | undefined;
|
|
43
|
+
let sync: { markers(): void; geo(): void; base(): void; style(): void; select(): void; view(): void } | undefined;
|
|
44
|
+
let failed = $state<string>();
|
|
45
|
+
|
|
46
|
+
const sel = $derived(markers.find((m) => m.id === selected));
|
|
47
|
+
|
|
48
|
+
onMount(() => {
|
|
49
|
+
let dispose = () => {};
|
|
50
|
+
let dead = false;
|
|
51
|
+
|
|
52
|
+
(async () => {
|
|
53
|
+
const [
|
|
54
|
+
{ default: OLMap }, { default: View }, { default: TileLayer }, { default: VectorLayer }, { default: VectorSource },
|
|
55
|
+
{ default: OSM }, { default: XYZ }, { default: Overlay }, { default: Feature }, { default: Point }, { default: GeoJSON },
|
|
56
|
+
{ Style, Circle: CircleStyle, Fill, Stroke, Text }, { fromLonLat, toLonLat }, { default: Attribution }, { default: ScaleLine },
|
|
57
|
+
{ default: Graticule }, { createEmpty, extend, isEmpty }
|
|
58
|
+
] = await Promise.all([
|
|
59
|
+
import('ol/Map.js'), import('ol/View.js'), import('ol/layer/Tile.js'), import('ol/layer/Vector.js'), import('ol/source/Vector.js'),
|
|
60
|
+
import('ol/source/OSM.js'), import('ol/source/XYZ.js'), import('ol/Overlay.js'), import('ol/Feature.js'), import('ol/geom/Point.js'), import('ol/format/GeoJSON.js'),
|
|
61
|
+
import('ol/style.js'), import('ol/proj.js'), import('ol/control/Attribution.js'), import('ol/control/ScaleLine.js'),
|
|
62
|
+
import('ol/layer/Graticule.js'), import('ol/extent.js')
|
|
63
|
+
]);
|
|
64
|
+
if (dead) return;
|
|
65
|
+
|
|
66
|
+
// ---- design tokens → concrete colors for canvas styles
|
|
67
|
+
let T: Record<string, string> = {};
|
|
68
|
+
const read = () => {
|
|
69
|
+
const cs = getComputedStyle(document.documentElement);
|
|
70
|
+
for (const n of ['primary', 'success', 'warning', 'danger', 'outline', 'surface', 'on-surface', 'surface-container-high', 'outline-variant'])
|
|
71
|
+
T[n] = cs.getPropertyValue(`--tr-${n}`).trim() || '#888888';
|
|
72
|
+
};
|
|
73
|
+
const rgba = (hex: string, a: number) => {
|
|
74
|
+
const h = hex.replace('#', '');
|
|
75
|
+
const f = h.length === 3 ? h.replace(/./g, '$&$&') : h.slice(0, 6);
|
|
76
|
+
const n = parseInt(f, 16);
|
|
77
|
+
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
|
78
|
+
};
|
|
79
|
+
const tone = (kind?: string) => (kind && kind !== 'neutral' ? T[kind] ?? T.primary : kind === 'neutral' ? T.outline : T.primary);
|
|
80
|
+
read();
|
|
81
|
+
|
|
82
|
+
const markerSrc = new VectorSource();
|
|
83
|
+
const geoSrc = new VectorSource();
|
|
84
|
+
|
|
85
|
+
const markerStyle = (f: any) => {
|
|
86
|
+
const on = f.getId() === selected;
|
|
87
|
+
const c = tone(f.get('kind'));
|
|
88
|
+
return [
|
|
89
|
+
...(on ? [new Style({ image: new CircleStyle({ radius: 14, fill: new Fill({ color: rgba(c, 0.25) }) }) })] : []),
|
|
90
|
+
new Style({
|
|
91
|
+
image: new CircleStyle({ radius: on ? 8 : 7, fill: new Fill({ color: c }), stroke: new Stroke({ color: T.surface, width: 2.5 }) }),
|
|
92
|
+
text: f.get('label')
|
|
93
|
+
? new Text({ text: f.get('label'), offsetY: 18, font: '600 12px Inter Variable, system-ui, sans-serif', fill: new Fill({ color: T['on-surface'] }), stroke: new Stroke({ color: T.surface, width: 3 }) })
|
|
94
|
+
: undefined
|
|
95
|
+
})
|
|
96
|
+
];
|
|
97
|
+
};
|
|
98
|
+
const geoStyle = (f: any) => {
|
|
99
|
+
const c = tone(f.get('kind'));
|
|
100
|
+
const t = f.getGeometry()?.getType();
|
|
101
|
+
if (t === 'Point' || t === 'MultiPoint') return new Style({ image: new CircleStyle({ radius: 5, fill: new Fill({ color: c }), stroke: new Stroke({ color: T.surface, width: 2 }) }) });
|
|
102
|
+
return new Style({ stroke: new Stroke({ color: c, width: 2.5, lineCap: 'round', lineJoin: 'round' }), fill: new Fill({ color: rgba(c, 0.14) }) });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const markerLayer = new VectorLayer({ source: markerSrc, style: markerStyle, zIndex: 20 });
|
|
106
|
+
const geoLayer = new VectorLayer({ source: geoSrc, style: geoStyle, zIndex: 10 });
|
|
107
|
+
|
|
108
|
+
let baseLayers: any[] = [];
|
|
109
|
+
const view = new View({ center: fromLonLat(center), zoom, minZoom: 1, maxZoom: 19 });
|
|
110
|
+
const map = new OLMap({
|
|
111
|
+
target: el,
|
|
112
|
+
view,
|
|
113
|
+
layers: [markerLayer, geoLayer],
|
|
114
|
+
controls: [new Attribution({ collapsible: true }), new ScaleLine({ units: 'metric', minWidth: 64 })],
|
|
115
|
+
keyboardEventTarget: el
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const overlay = new Overlay({ element: popupEl, positioning: 'bottom-center', offset: [0, -16], stopEvent: true });
|
|
119
|
+
map.addOverlay(overlay);
|
|
120
|
+
|
|
121
|
+
// ---- sync functions (called from effects)
|
|
122
|
+
sync = {
|
|
123
|
+
base() {
|
|
124
|
+
for (const l of baseLayers) map.removeLayer(l);
|
|
125
|
+
baseLayers = [];
|
|
126
|
+
if (basemap === 'osm') baseLayers = [new TileLayer({ source: new OSM(), className: 'tr-basemap', zIndex: 0 })];
|
|
127
|
+
else if (basemap === 'none') {
|
|
128
|
+
baseLayers = [new Graticule({ strokeStyle: new Stroke({ color: rgba(T.outline, 0.45), width: 1 }), showLabels: true, wrapX: true, zIndex: 0,
|
|
129
|
+
latLabelStyle: new Text({ font: '11px Inter Variable, system-ui', fill: new Fill({ color: T.outline }) }),
|
|
130
|
+
lonLabelStyle: new Text({ font: '11px Inter Variable, system-ui', fill: new Fill({ color: T.outline }) }) })];
|
|
131
|
+
} else baseLayers = [new TileLayer({ source: new XYZ({ url: basemap.url, attributions: basemap.attribution, maxZoom: basemap.maxZoom ?? 19 }), className: 'tr-basemap', zIndex: 0 })];
|
|
132
|
+
for (const l of baseLayers) map.addLayer(l);
|
|
133
|
+
},
|
|
134
|
+
markers() {
|
|
135
|
+
markerSrc.clear();
|
|
136
|
+
markerSrc.addFeatures(markers.map((m) => {
|
|
137
|
+
const f = new Feature({ geometry: new Point(fromLonLat([m.lon, m.lat])), label: m.label, kind: m.kind });
|
|
138
|
+
f.setId(m.id);
|
|
139
|
+
return f;
|
|
140
|
+
}));
|
|
141
|
+
},
|
|
142
|
+
geo() {
|
|
143
|
+
geoSrc.clear();
|
|
144
|
+
if (geojson) geoSrc.addFeatures(new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' }));
|
|
145
|
+
},
|
|
146
|
+
style() { read(); markerLayer.changed(); geoLayer.changed(); this.base(); },
|
|
147
|
+
select() {
|
|
148
|
+
markerLayer.changed();
|
|
149
|
+
const m = markers.find((x) => x.id === selected);
|
|
150
|
+
overlay.setPosition(m ? fromLonLat([m.lon, m.lat]) : undefined);
|
|
151
|
+
},
|
|
152
|
+
view() {
|
|
153
|
+
const c = toLonLat(view.getCenter()!);
|
|
154
|
+
if (Math.abs(c[0] - center[0]) > 1e-6 || Math.abs(c[1] - center[1]) > 1e-6) view.animate({ center: fromLonLat(center), duration: 250 });
|
|
155
|
+
if (Math.abs((view.getZoom() ?? 0) - zoom) > 1e-6) view.animate({ zoom, duration: 250 });
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
ctl = {
|
|
159
|
+
zoomBy: (d) => view.animate({ zoom: (view.getZoom() ?? 0) + d, duration: 200 }),
|
|
160
|
+
fit: () => {
|
|
161
|
+
const ext = createEmpty();
|
|
162
|
+
for (const src of [markerSrc, geoSrc]) { const e = src.getExtent(); if (e) extend(ext, e); }
|
|
163
|
+
if (!isEmpty(ext)) view.fit(ext, { padding: [56, 56, 56, 56], maxZoom: 12, duration: 300 });
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// ---- user interaction
|
|
168
|
+
map.on('moveend', () => {
|
|
169
|
+
const c = toLonLat(view.getCenter()!);
|
|
170
|
+
center = [Number(c[0].toFixed(5)), Number(c[1].toFixed(5))];
|
|
171
|
+
zoom = Number((view.getZoom() ?? 0).toFixed(2));
|
|
172
|
+
});
|
|
173
|
+
map.on('singleclick', (e) => {
|
|
174
|
+
const hit = map.forEachFeatureAtPixel(e.pixel, (f, l) => ({ f, l }), { hitTolerance: 6 });
|
|
175
|
+
if (hit && hit.l === markerLayer) {
|
|
176
|
+
selected = String(hit.f.getId());
|
|
177
|
+
const m = markers.find((x) => x.id === selected)!;
|
|
178
|
+
onselect?.({ type: 'marker', marker: m });
|
|
179
|
+
} else if (hit) {
|
|
180
|
+
selected = undefined;
|
|
181
|
+
const { geometry, ...properties } = hit.f.getProperties();
|
|
182
|
+
onselect?.({ type: 'feature', id: hit.f.getId(), properties });
|
|
183
|
+
} else { selected = undefined; onselect?.(null); }
|
|
184
|
+
});
|
|
185
|
+
map.on('pointermove', (e) => {
|
|
186
|
+
if (e.dragging) return;
|
|
187
|
+
el.style.cursor = map.hasFeatureAtPixel(e.pixel, { hitTolerance: 6 }) ? 'pointer' : '';
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---- follow theme (data-theme toggle or OS preference)
|
|
191
|
+
const restyle = () => sync?.style();
|
|
192
|
+
const mo = new MutationObserver(restyle);
|
|
193
|
+
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
194
|
+
const mq = matchMedia('(prefers-color-scheme: dark)');
|
|
195
|
+
mq.addEventListener('change', restyle);
|
|
196
|
+
|
|
197
|
+
sync.base(); sync.markers(); sync.geo();
|
|
198
|
+
if (fitOnLoad && (markers.length || geojson)) ctl.fit();
|
|
199
|
+
ready = true;
|
|
200
|
+
|
|
201
|
+
dispose = () => {
|
|
202
|
+
mo.disconnect(); mq.removeEventListener('change', restyle);
|
|
203
|
+
map.setTarget(undefined);
|
|
204
|
+
};
|
|
205
|
+
})().catch((e) => (failed = e instanceof Error ? e.message : String(e)));
|
|
206
|
+
|
|
207
|
+
return () => { dead = true; dispose(); };
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
$effect(() => { markers; if (ready) { sync?.markers(); sync?.select(); } });
|
|
211
|
+
$effect(() => { geojson; if (ready) sync?.geo(); });
|
|
212
|
+
$effect(() => { basemap; if (ready) sync?.base(); });
|
|
213
|
+
$effect(() => { selected; if (ready) sync?.select(); });
|
|
214
|
+
$effect(() => { center; zoom; if (ready) sync?.view(); });
|
|
215
|
+
</script>
|
|
216
|
+
|
|
217
|
+
<div class="trm" style:height={typeof height === 'number' ? `${height}px` : height}>
|
|
218
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
219
|
+
<div class="map" bind:this={el} tabindex="0" role="application" aria-label="Map. Arrow keys pan, plus and minus zoom."></div>
|
|
220
|
+
|
|
221
|
+
<div class="popup" bind:this={popupEl} hidden={!sel}>
|
|
222
|
+
{#if sel}
|
|
223
|
+
<div class="card" role="dialog" aria-label={sel.label ?? 'Location'}>
|
|
224
|
+
<button type="button" class="x" aria-label="Close" onclick={() => { selected = undefined; onselect?.(null); }}>×</button>
|
|
225
|
+
{#if popup}{@render popup(sel)}{:else}
|
|
226
|
+
<div class="t">{sel.label ?? 'Location'}</div>
|
|
227
|
+
{#if sel.description}<div class="d">{sel.description}</div>{/if}
|
|
228
|
+
<div class="c">{sel.lat.toFixed(4)}, {sel.lon.toFixed(4)}</div>
|
|
229
|
+
{/if}
|
|
230
|
+
</div>
|
|
231
|
+
{/if}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div class="controls" role="toolbar" aria-label="Map controls">
|
|
235
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="plus" aria-label="Zoom in" onclick={() => ctl?.zoomBy(1)} />
|
|
236
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="minus" aria-label="Zoom out" onclick={() => ctl?.zoomBy(-1)} />
|
|
237
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="expand" aria-label="Fit to data" onclick={() => ctl?.fit()} />
|
|
238
|
+
{#if toolbar}{@render toolbar()}{/if}
|
|
239
|
+
</div>
|
|
240
|
+
{#if failed}<div class="err" role="alert">Map failed to load: {failed}</div>{/if}
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<style>
|
|
244
|
+
.trm { position: relative; overflow: hidden; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface-container); }
|
|
245
|
+
.map { position: absolute; inset: 0; outline: none; }
|
|
246
|
+
.map:focus-visible { box-shadow: inset 0 0 0 2px var(--tr-primary); }
|
|
247
|
+
.controls { position: absolute; z-index: 3; left: 8px; top: 8px; display: flex; flex-direction: column; gap: 2px; padding: 2px; background: var(--tr-surface-container-highest); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); box-shadow: var(--tr-elevation-2); }
|
|
248
|
+
.popup[hidden] { display: none; }
|
|
249
|
+
.card { position: relative; min-width: 160px; max-width: 260px; padding: 10px 28px 10px 12px; background: var(--tr-surface-container-low); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); box-shadow: var(--tr-elevation-3); color: var(--tr-on-surface); font: var(--tr-text-md) var(--tr-font-sans); cursor: default; }
|
|
250
|
+
.card::after { content: ''; position: absolute; left: 50%; bottom: -6px; width: 10px; height: 10px; transform: translateX(-50%) rotate(45deg); background: var(--tr-surface-container-low); border-right: 1px solid var(--tr-outline-variant); border-bottom: 1px solid var(--tr-outline-variant); }
|
|
251
|
+
.t { font-weight: 700; }
|
|
252
|
+
.d { color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); margin-top: 2px; }
|
|
253
|
+
.c { margin-top: 6px; color: var(--tr-on-surface-variant); font: var(--tr-text-xs) var(--tr-font-mono); }
|
|
254
|
+
.x { position: absolute; top: 4px; right: 4px; width: 22px; height: 22px; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface-variant); font-size: 16px; line-height: 1; cursor: pointer; }
|
|
255
|
+
.x:hover { background: var(--tr-surface-container-highest); }
|
|
256
|
+
.err { position: absolute; inset: auto 8px 8px; padding: 8px 12px; border-radius: var(--tr-radius-md); background: var(--tr-danger-container); color: var(--tr-on-danger-container); }
|
|
257
|
+
|
|
258
|
+
/* Dark mode: tint raster tiles to the theme */
|
|
259
|
+
:global(:root[data-theme='dark'] .tr-basemap) { filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.9) saturate(0.7); }
|
|
260
|
+
@media (prefers-color-scheme: dark) {
|
|
261
|
+
:global(:root:not([data-theme='light']) .tr-basemap) { filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.9) saturate(0.7); }
|
|
262
|
+
}
|
|
263
|
+
/* Restyle OpenLayers' built-in controls with design tokens */
|
|
264
|
+
:global(.trm .ol-attribution) { right: 8px; bottom: 8px; max-width: 70%; background: var(--tr-surface-container-highest); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-pill); font: var(--tr-text-xs) var(--tr-font-sans); color: var(--tr-on-surface-variant); }
|
|
265
|
+
:global(.trm .ol-attribution ul) { margin: 0; padding: 2px 10px; color: var(--tr-on-surface-variant); text-shadow: none; }
|
|
266
|
+
:global(.trm .ol-attribution a) { color: var(--tr-primary); }
|
|
267
|
+
:global(.trm .ol-attribution button) { background: none; color: var(--tr-on-surface-variant); }
|
|
268
|
+
:global(.trm .ol-attribution button:hover) { background: var(--tr-surface-container-high); }
|
|
269
|
+
:global(.trm .ol-scale-line) { left: auto; right: 8px; bottom: 36px; background: var(--tr-surface-container-highest); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-pill); padding: 2px 8px; }
|
|
270
|
+
:global(.trm .ol-scale-line-inner) { border-color: var(--tr-on-surface-variant); color: var(--tr-on-surface-variant); font: var(--tr-text-xs) var(--tr-font-sans); }
|
|
271
|
+
</style>
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Map } from './Map.svelte';
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@ruckc/truss-ol",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenLayers map styled with Truss UI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"package": "svelte-package -i src/lib -o dist"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"sideEffects": [
|
|
14
|
+
"**/*.css"
|
|
15
|
+
],
|
|
16
|
+
"svelte": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"svelte": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"svelte": "^5.20.0",
|
|
26
|
+
"@ruckc/truss-ui": "^1.0.0",
|
|
27
|
+
"ol": "^10.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"ol": "^10.0.0"
|
|
31
|
+
},
|
|
32
|
+
"author": "Curtis Ruck",
|
|
33
|
+
"homepage": "https://truss.ruck.io",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/ruckc/truss-ui/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/ruckc/truss-ui.git",
|
|
43
|
+
"directory": "packages/map"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"svelte",
|
|
47
|
+
"sveltekit",
|
|
48
|
+
"truss",
|
|
49
|
+
"map",
|
|
50
|
+
"openlayers"
|
|
51
|
+
]
|
|
52
|
+
}
|
package/README.md
DELETED