@aiaiai-pt/design-system 0.4.4 → 0.5.1
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/components/Button.svelte +4 -0
- package/components/Calendar.svelte +971 -0
- package/components/Calendar.svelte.d.ts +50 -0
- package/components/DatePicker.svelte +473 -0
- package/components/DatePicker.svelte.d.ts +59 -0
- package/components/DateRangePicker.svelte +558 -0
- package/components/DateRangePicker.svelte.d.ts +55 -0
- package/components/DateTimePicker.svelte +275 -0
- package/components/DateTimePicker.svelte.d.ts +55 -0
- package/components/MapCluster.svelte +220 -0
- package/components/MapCluster.svelte.d.ts +39 -0
- package/components/MapDisplay.svelte +139 -0
- package/components/MapDisplay.svelte.d.ts +35 -0
- package/components/MapHeatmap.svelte +164 -0
- package/components/MapHeatmap.svelte.d.ts +50 -0
- package/components/MapPicker.svelte +243 -0
- package/components/MapPicker.svelte.d.ts +49 -0
- package/components/MapPopup.svelte +101 -0
- package/components/MapPopup.svelte.d.ts +30 -0
- package/components/Select.svelte +3 -4
- package/components/StatCard.svelte +195 -0
- package/components/StatCard.svelte.d.ts +42 -0
- package/components/StatGrid.svelte +39 -0
- package/components/StatGrid.svelte.d.ts +29 -0
- package/components/index.d.ts +12 -0
- package/components/index.js +17 -0
- package/components/map-utils.d.ts +100 -0
- package/components/map-utils.js +338 -0
- package/package.json +8 -1
- package/tokens/components.css +215 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component DateTimePicker
|
|
3
|
+
|
|
4
|
+
Date + time selection. Composes DatePicker with styled hour/minute selects.
|
|
5
|
+
Values displayed in Berkeley Mono (data font).
|
|
6
|
+
Consumes --datepicker-* and --input-* tokens from components.css.
|
|
7
|
+
|
|
8
|
+
@example Basic
|
|
9
|
+
<DateTimePicker label="SCHEDULED AT" bind:value={scheduledAt} />
|
|
10
|
+
|
|
11
|
+
@example With constraints
|
|
12
|
+
<DateTimePicker label="MEETING" min={new Date()} displayFormat="dd/MM/yyyy HH:mm" />
|
|
13
|
+
-->
|
|
14
|
+
<script module>
|
|
15
|
+
let _datetimepickerUid = 0;
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script>
|
|
19
|
+
import { format } from 'date-fns';
|
|
20
|
+
import { enUS } from 'date-fns/locale';
|
|
21
|
+
import DatePicker from './DatePicker.svelte';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {'sm' | 'md' | 'lg'} Size
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
/** @type {Date | null} */
|
|
29
|
+
value = $bindable(null),
|
|
30
|
+
/** @type {string | undefined} */
|
|
31
|
+
label = undefined,
|
|
32
|
+
/** @type {string | undefined} */
|
|
33
|
+
placeholder = 'Select date and time',
|
|
34
|
+
/** @type {string | undefined} */
|
|
35
|
+
help = undefined,
|
|
36
|
+
/** @type {string | undefined} */
|
|
37
|
+
error = undefined,
|
|
38
|
+
/** @type {Size} */
|
|
39
|
+
size = 'md',
|
|
40
|
+
/** @type {boolean} */
|
|
41
|
+
disabled = false,
|
|
42
|
+
/** @type {boolean} */
|
|
43
|
+
readonly = false,
|
|
44
|
+
/** @type {Date | undefined} */
|
|
45
|
+
min = undefined,
|
|
46
|
+
/** @type {Date | undefined} */
|
|
47
|
+
max = undefined,
|
|
48
|
+
/** @type {string} */
|
|
49
|
+
displayFormat = 'dd/MM/yyyy HH:mm',
|
|
50
|
+
/** @type {import('date-fns').Locale} */
|
|
51
|
+
locale = enUS,
|
|
52
|
+
/** @type {number} */
|
|
53
|
+
minuteStep = 5,
|
|
54
|
+
/** @type {((date: Date) => void) | undefined} */
|
|
55
|
+
onchange = undefined,
|
|
56
|
+
/** @type {string | undefined} */
|
|
57
|
+
id = undefined,
|
|
58
|
+
/** @type {string} */
|
|
59
|
+
class: className = '',
|
|
60
|
+
...rest
|
|
61
|
+
} = $props();
|
|
62
|
+
|
|
63
|
+
const fallbackId = `datetimepicker-${_datetimepickerUid++}`;
|
|
64
|
+
const pickerId = $derived(id ?? fallbackId);
|
|
65
|
+
const hintId = $derived(`${pickerId}-hint`);
|
|
66
|
+
const hasHint = $derived(!!error || !!help);
|
|
67
|
+
|
|
68
|
+
// Split value into date and time parts
|
|
69
|
+
let selectedDate = $state(value ? new Date(value) : null);
|
|
70
|
+
let hours = $state(value ? value.getHours() : 9);
|
|
71
|
+
let minutes = $state(value ? value.getMinutes() : 0);
|
|
72
|
+
|
|
73
|
+
// Generate minute options based on step
|
|
74
|
+
const minuteOptions = $derived(
|
|
75
|
+
Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** @param {Date} date */
|
|
79
|
+
function handleDateChange(date) {
|
|
80
|
+
selectedDate = date;
|
|
81
|
+
syncValue(date, hours, minutes);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @param {Event} e */
|
|
85
|
+
function handleHourChange(e) {
|
|
86
|
+
const h = Number(/** @type {HTMLSelectElement} */ (e.target).value);
|
|
87
|
+
hours = h;
|
|
88
|
+
if (selectedDate) syncValue(selectedDate, h, minutes);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {Event} e */
|
|
92
|
+
function handleMinuteChange(e) {
|
|
93
|
+
const m = Number(/** @type {HTMLSelectElement} */ (e.target).value);
|
|
94
|
+
minutes = m;
|
|
95
|
+
if (selectedDate) syncValue(selectedDate, hours, m);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {Date} date @param {number} h @param {number} m */
|
|
99
|
+
function syncValue(date, h, m) {
|
|
100
|
+
const merged = new Date(date);
|
|
101
|
+
merged.setHours(h, m, 0, 0);
|
|
102
|
+
value = merged;
|
|
103
|
+
onchange?.(merged);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Sync when value changes externally
|
|
107
|
+
$effect(() => {
|
|
108
|
+
if (value) {
|
|
109
|
+
selectedDate = new Date(value);
|
|
110
|
+
hours = value.getHours();
|
|
111
|
+
minutes = value.getMinutes();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<div class="datetimepicker {className}" {...rest}>
|
|
117
|
+
{#if label}
|
|
118
|
+
<label class="datetimepicker-label" for={pickerId}>{label}</label>
|
|
119
|
+
{/if}
|
|
120
|
+
|
|
121
|
+
<div class="datetimepicker-row">
|
|
122
|
+
<div class="datetimepicker-date">
|
|
123
|
+
<DatePicker
|
|
124
|
+
bind:value={selectedDate}
|
|
125
|
+
placeholder={value ? undefined : placeholder}
|
|
126
|
+
{size}
|
|
127
|
+
{disabled}
|
|
128
|
+
{readonly}
|
|
129
|
+
{min}
|
|
130
|
+
{max}
|
|
131
|
+
{locale}
|
|
132
|
+
onchange={handleDateChange}
|
|
133
|
+
id={pickerId}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div class="datetimepicker-time">
|
|
138
|
+
<select
|
|
139
|
+
class="datetimepicker-select datetimepicker-select-{size}"
|
|
140
|
+
class:datetimepicker-select-error={!!error}
|
|
141
|
+
value={hours}
|
|
142
|
+
{disabled}
|
|
143
|
+
onchange={handleHourChange}
|
|
144
|
+
aria-label="Hour"
|
|
145
|
+
>
|
|
146
|
+
{#each Array.from({ length: 24 }, (_, i) => i) as h}
|
|
147
|
+
<option value={h}>{String(h).padStart(2, '0')}</option>
|
|
148
|
+
{/each}
|
|
149
|
+
</select>
|
|
150
|
+
<span class="datetimepicker-colon" aria-hidden="true">:</span>
|
|
151
|
+
<select
|
|
152
|
+
class="datetimepicker-select datetimepicker-select-{size}"
|
|
153
|
+
class:datetimepicker-select-error={!!error}
|
|
154
|
+
value={minutes}
|
|
155
|
+
{disabled}
|
|
156
|
+
onchange={handleMinuteChange}
|
|
157
|
+
aria-label="Minute"
|
|
158
|
+
>
|
|
159
|
+
{#each minuteOptions as m}
|
|
160
|
+
<option value={m}>{String(m).padStart(2, '0')}</option>
|
|
161
|
+
{/each}
|
|
162
|
+
</select>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{#if error}
|
|
167
|
+
<span id={hintId} class="datetimepicker-error-text" role="alert">{error}</span>
|
|
168
|
+
{:else if help}
|
|
169
|
+
<span id={hintId} class="datetimepicker-help">{help}</span>
|
|
170
|
+
{/if}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<style>
|
|
174
|
+
.datetimepicker {
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-direction: column;
|
|
177
|
+
gap: var(--input-label-gap);
|
|
178
|
+
width: 100%;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.datetimepicker-label {
|
|
182
|
+
font-family: var(--input-label-font);
|
|
183
|
+
font-size: var(--input-label-size);
|
|
184
|
+
letter-spacing: var(--input-label-tracking);
|
|
185
|
+
color: var(--input-label-color);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.datetimepicker-row {
|
|
189
|
+
display: flex;
|
|
190
|
+
gap: var(--space-xs);
|
|
191
|
+
align-items: stretch;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.datetimepicker-date {
|
|
195
|
+
flex: 1;
|
|
196
|
+
min-width: 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.datetimepicker-time {
|
|
200
|
+
display: flex;
|
|
201
|
+
align-items: center;
|
|
202
|
+
gap: 0;
|
|
203
|
+
flex-shrink: 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.datetimepicker-colon {
|
|
207
|
+
font-family: var(--input-font);
|
|
208
|
+
font-size: var(--input-font-size);
|
|
209
|
+
color: var(--color-text-secondary);
|
|
210
|
+
padding: 0 var(--space-2xs);
|
|
211
|
+
user-select: none;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.datetimepicker-select {
|
|
215
|
+
font-family: var(--input-font);
|
|
216
|
+
font-size: var(--input-font-size);
|
|
217
|
+
border: var(--input-border);
|
|
218
|
+
border-radius: var(--input-radius);
|
|
219
|
+
background: var(--input-bg);
|
|
220
|
+
color: var(--input-text);
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
transition: border var(--input-transition);
|
|
223
|
+
appearance: none;
|
|
224
|
+
-webkit-appearance: none;
|
|
225
|
+
text-align: center;
|
|
226
|
+
width: 3.2em;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.datetimepicker-select-sm {
|
|
230
|
+
height: var(--input-sm-height);
|
|
231
|
+
padding: 0 var(--space-xs);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.datetimepicker-select-md {
|
|
235
|
+
height: var(--input-md-height);
|
|
236
|
+
padding: 0 var(--space-xs);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.datetimepicker-select-lg {
|
|
240
|
+
height: var(--input-lg-height);
|
|
241
|
+
padding: 0 var(--space-sm);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.datetimepicker-select:focus {
|
|
245
|
+
outline: none;
|
|
246
|
+
border: var(--input-border-focus);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.datetimepicker-select:disabled {
|
|
250
|
+
opacity: 0.5;
|
|
251
|
+
cursor: not-allowed;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.datetimepicker-select-error {
|
|
255
|
+
border-color: var(--input-error-border-color);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.datetimepicker-help {
|
|
259
|
+
font-family: var(--input-help-font);
|
|
260
|
+
font-size: var(--input-help-size);
|
|
261
|
+
color: var(--input-help-color);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.datetimepicker-error-text {
|
|
265
|
+
font-family: var(--input-help-font);
|
|
266
|
+
font-size: var(--input-help-size);
|
|
267
|
+
color: var(--input-error-text);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@media (prefers-reduced-motion: reduce) {
|
|
271
|
+
.datetimepicker-select {
|
|
272
|
+
transition: none;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export default DateTimePicker;
|
|
2
|
+
type DateTimePicker = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* DateTimePicker
|
|
8
|
+
*
|
|
9
|
+
* Date + time selection. Composes DatePicker with a time input.
|
|
10
|
+
* Values displayed in Berkeley Mono (data font).
|
|
11
|
+
* Consumes --datepicker-* and --input-* tokens from components.css.
|
|
12
|
+
*
|
|
13
|
+
* @example Basic
|
|
14
|
+
* <DateTimePicker label="SCHEDULED AT" bind:value={scheduledAt} />
|
|
15
|
+
*
|
|
16
|
+
* @example With constraints
|
|
17
|
+
* <DateTimePicker label="MEETING" min={new Date()} displayFormat="dd/MM/yyyy HH:mm" />
|
|
18
|
+
*/
|
|
19
|
+
declare const DateTimePicker: import("svelte").Component<{
|
|
20
|
+
value?: any;
|
|
21
|
+
label?: any;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
help?: any;
|
|
24
|
+
error?: any;
|
|
25
|
+
size?: string;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
readonly?: boolean;
|
|
28
|
+
min?: any;
|
|
29
|
+
max?: any;
|
|
30
|
+
displayFormat?: string;
|
|
31
|
+
locale?: typeof enUS;
|
|
32
|
+
minuteStep?: number;
|
|
33
|
+
onchange?: any;
|
|
34
|
+
id?: any;
|
|
35
|
+
class?: string;
|
|
36
|
+
} & Record<string, any>, {}, "value">;
|
|
37
|
+
type $$ComponentProps = {
|
|
38
|
+
value?: any;
|
|
39
|
+
label?: any;
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
help?: any;
|
|
42
|
+
error?: any;
|
|
43
|
+
size?: string;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
readonly?: boolean;
|
|
46
|
+
min?: any;
|
|
47
|
+
max?: any;
|
|
48
|
+
displayFormat?: string;
|
|
49
|
+
locale?: typeof enUS;
|
|
50
|
+
minuteStep?: number;
|
|
51
|
+
onchange?: any;
|
|
52
|
+
id?: any;
|
|
53
|
+
class?: string;
|
|
54
|
+
} & Record<string, any>;
|
|
55
|
+
import { enUS } from 'date-fns/locale';
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component MapCluster
|
|
3
|
+
|
|
4
|
+
Map with clustered markers, hover tooltips, and click-to-select.
|
|
5
|
+
OpenLayers with built-in Cluster source and OL Overlay for tooltips.
|
|
6
|
+
Styles cached via shared style factory for render performance.
|
|
7
|
+
Consumes --map-* tokens from components.css.
|
|
8
|
+
|
|
9
|
+
@example
|
|
10
|
+
<MapCluster
|
|
11
|
+
markers={[{ id: '1', lon: -9.14, lat: 38.74, label: 'HQ' }]}
|
|
12
|
+
onclick={(marker) => goto(`/equipment/${marker.id}`)}
|
|
13
|
+
/>
|
|
14
|
+
-->
|
|
15
|
+
<script>
|
|
16
|
+
import { fromLonLat } from 'ol/proj.js';
|
|
17
|
+
import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
/** @type {{ id: string, lon: number, lat: number, label?: string, [key: string]: any }[]} */
|
|
21
|
+
markers = [],
|
|
22
|
+
/** @type {[number, number]} — initial center [lon, lat] */
|
|
23
|
+
center = [0, 0],
|
|
24
|
+
/** @type {number} */
|
|
25
|
+
zoom = 6,
|
|
26
|
+
/** @type {number} — cluster distance in pixels */
|
|
27
|
+
distance = 40,
|
|
28
|
+
/** @type {import('./map-utils.js').TileSourceConfig} */
|
|
29
|
+
tileSource = { type: 'osm' },
|
|
30
|
+
/** @type {((marker: { id: string, lon: number, lat: number, label?: string }) => void) | undefined} */
|
|
31
|
+
onclick = undefined,
|
|
32
|
+
/** @type {string} */
|
|
33
|
+
height = '100%',
|
|
34
|
+
/** @type {string} */
|
|
35
|
+
class: className = '',
|
|
36
|
+
...rest
|
|
37
|
+
} = $props();
|
|
38
|
+
|
|
39
|
+
/** @type {HTMLElement | undefined} */
|
|
40
|
+
let container = $state();
|
|
41
|
+
|
|
42
|
+
$effect(() => {
|
|
43
|
+
if (!container) return;
|
|
44
|
+
|
|
45
|
+
let disposed = false;
|
|
46
|
+
/** @type {import('ol/Map.js').default | undefined} */
|
|
47
|
+
let map;
|
|
48
|
+
/** @type {(() => void) | undefined} */
|
|
49
|
+
let disposeTheme;
|
|
50
|
+
|
|
51
|
+
(async () => { try {
|
|
52
|
+
const [
|
|
53
|
+
{ default: OlMap },
|
|
54
|
+
{ default: View },
|
|
55
|
+
{ default: VectorLayer },
|
|
56
|
+
{ default: VectorSource },
|
|
57
|
+
{ default: Cluster },
|
|
58
|
+
{ default: Feature },
|
|
59
|
+
{ default: Point },
|
|
60
|
+
{ default: Overlay },
|
|
61
|
+
] = await Promise.all([
|
|
62
|
+
import('ol/Map.js'),
|
|
63
|
+
import('ol/View.js'),
|
|
64
|
+
import('ol/layer/Vector.js'),
|
|
65
|
+
import('ol/source/Vector.js'),
|
|
66
|
+
import('ol/source/Cluster.js'),
|
|
67
|
+
import('ol/Feature.js'),
|
|
68
|
+
import('ol/geom/Point.js'),
|
|
69
|
+
import('ol/Overlay.js'),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
if (disposed) return;
|
|
73
|
+
|
|
74
|
+
const [tileLayer, styles] = await Promise.all([
|
|
75
|
+
createTileLayer(tileSource),
|
|
76
|
+
createMapStyles(container),
|
|
77
|
+
]);
|
|
78
|
+
if (disposed) return;
|
|
79
|
+
|
|
80
|
+
const features = markers.map(m => {
|
|
81
|
+
const f = new Feature({ geometry: new Point(fromLonLat([m.lon, m.lat])) });
|
|
82
|
+
f.set('markerData', m);
|
|
83
|
+
return f;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const vectorSource = new VectorSource({ features });
|
|
87
|
+
const clusterSource = new Cluster({ distance, source: vectorSource });
|
|
88
|
+
|
|
89
|
+
const clusterLayer = new VectorLayer({
|
|
90
|
+
source: clusterSource,
|
|
91
|
+
style: (feature) => {
|
|
92
|
+
const size = feature.get('features')?.length ?? 1;
|
|
93
|
+
return styles.cluster(size);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Tooltip overlay
|
|
98
|
+
const tooltipEl = document.createElement('div');
|
|
99
|
+
tooltipEl.style.cssText = `
|
|
100
|
+
background: var(--map-popup-bg, #fff);
|
|
101
|
+
border: var(--map-popup-border, 1px solid #ddd);
|
|
102
|
+
border-radius: var(--map-popup-radius, 4px);
|
|
103
|
+
padding: var(--map-popup-padding, 8px);
|
|
104
|
+
box-shadow: var(--map-popup-shadow, 0 2px 8px rgba(0,0,0,0.15));
|
|
105
|
+
font-family: var(--type-body-sm-font, sans-serif);
|
|
106
|
+
font-size: var(--type-body-sm-size, 13px);
|
|
107
|
+
color: var(--color-text, #2c2825);
|
|
108
|
+
pointer-events: none;
|
|
109
|
+
white-space: nowrap;
|
|
110
|
+
`;
|
|
111
|
+
tooltipEl.style.display = 'none';
|
|
112
|
+
container.appendChild(tooltipEl);
|
|
113
|
+
|
|
114
|
+
const tooltipOverlay = new Overlay({
|
|
115
|
+
element: tooltipEl,
|
|
116
|
+
positioning: 'bottom-center',
|
|
117
|
+
offset: [0, -12],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const viewCenter = markers.length > 0
|
|
121
|
+
? fromLonLat([
|
|
122
|
+
markers.reduce((s, m) => s + m.lon, 0) / markers.length,
|
|
123
|
+
markers.reduce((s, m) => s + m.lat, 0) / markers.length,
|
|
124
|
+
])
|
|
125
|
+
: fromLonLat(center);
|
|
126
|
+
|
|
127
|
+
map = new OlMap({
|
|
128
|
+
target: container,
|
|
129
|
+
layers: [tileLayer, clusterLayer],
|
|
130
|
+
overlays: [tooltipOverlay],
|
|
131
|
+
view: new View({
|
|
132
|
+
center: viewCenter,
|
|
133
|
+
zoom,
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Hover: show tooltip
|
|
138
|
+
map.on('pointermove', (evt) => {
|
|
139
|
+
const feature = map?.forEachFeatureAtPixel(evt.pixel, f => f);
|
|
140
|
+
if (!feature) {
|
|
141
|
+
tooltipEl.style.display = 'none';
|
|
142
|
+
if (container) container.style.cursor = '';
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const clustered = feature.get('features');
|
|
147
|
+
if (container) container.style.cursor = 'pointer';
|
|
148
|
+
|
|
149
|
+
if (clustered?.length === 1) {
|
|
150
|
+
const data = clustered[0].get('markerData');
|
|
151
|
+
if (data?.label) {
|
|
152
|
+
tooltipEl.textContent = data.label;
|
|
153
|
+
tooltipEl.style.display = 'block';
|
|
154
|
+
tooltipOverlay.setPosition(feature.getGeometry()?.getCoordinates());
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
tooltipEl.textContent = `${clustered?.length ?? 0} items`;
|
|
158
|
+
tooltipEl.style.display = 'block';
|
|
159
|
+
tooltipOverlay.setPosition(feature.getGeometry()?.getCoordinates());
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Click handler
|
|
164
|
+
if (onclick) {
|
|
165
|
+
map.on('click', (evt) => {
|
|
166
|
+
const feature = map?.forEachFeatureAtPixel(evt.pixel, f => f);
|
|
167
|
+
if (!feature) return;
|
|
168
|
+
|
|
169
|
+
const clustered = feature.get('features');
|
|
170
|
+
if (clustered?.length === 1) {
|
|
171
|
+
const data = clustered[0].get('markerData');
|
|
172
|
+
if (data) onclick(data);
|
|
173
|
+
} else if (clustered?.length > 1) {
|
|
174
|
+
const view = map?.getView();
|
|
175
|
+
const currentZoom = view?.getZoom() ?? zoom;
|
|
176
|
+
view?.animate({
|
|
177
|
+
center: feature.getGeometry()?.getCoordinates(),
|
|
178
|
+
zoom: currentZoom + 2,
|
|
179
|
+
duration: 300,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
disposeTheme = watchTheme(() => {
|
|
186
|
+
styles.refresh();
|
|
187
|
+
clusterSource.changed();
|
|
188
|
+
});
|
|
189
|
+
} catch (err) { renderMapError(container, 'MapCluster', /** @type {Error} */ (err)); } })();
|
|
190
|
+
|
|
191
|
+
return () => {
|
|
192
|
+
disposed = true;
|
|
193
|
+
disposeTheme?.();
|
|
194
|
+
map?.setTarget(undefined);
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
</script>
|
|
198
|
+
|
|
199
|
+
<div
|
|
200
|
+
bind:this={container}
|
|
201
|
+
class="map-cluster {className}"
|
|
202
|
+
style:height
|
|
203
|
+
role="application"
|
|
204
|
+
aria-label="Map with clustered markers"
|
|
205
|
+
{...rest}
|
|
206
|
+
></div>
|
|
207
|
+
|
|
208
|
+
<style>
|
|
209
|
+
.map-cluster {
|
|
210
|
+
width: 100%;
|
|
211
|
+
border: var(--map-border);
|
|
212
|
+
border-radius: var(--map-radius);
|
|
213
|
+
overflow: hidden;
|
|
214
|
+
position: relative;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.map-cluster :global(.ol-viewport) {
|
|
218
|
+
border-radius: inherit;
|
|
219
|
+
}
|
|
220
|
+
</style>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default MapCluster;
|
|
2
|
+
type MapCluster = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* MapCluster
|
|
8
|
+
*
|
|
9
|
+
* Map with clustered markers, hover tooltips, and click-to-select.
|
|
10
|
+
* OpenLayers with built-in Cluster source and OL Overlay for tooltips.
|
|
11
|
+
* Styles cached via shared style factory for render performance.
|
|
12
|
+
* Consumes --map-* tokens from components.css.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <MapCluster
|
|
16
|
+
* markers={[{ id: '1', lon: -9.14, lat: 38.74, label: 'HQ' }]}
|
|
17
|
+
* onclick={(marker) => goto(`/equipment/${marker.id}`)}
|
|
18
|
+
* />
|
|
19
|
+
*/
|
|
20
|
+
declare const MapCluster: import("svelte").Component<{
|
|
21
|
+
markers?: any[];
|
|
22
|
+
center?: any[];
|
|
23
|
+
zoom?: number;
|
|
24
|
+
distance?: number;
|
|
25
|
+
tileSource?: Record<string, any>;
|
|
26
|
+
onclick?: any;
|
|
27
|
+
height?: string;
|
|
28
|
+
class?: string;
|
|
29
|
+
} & Record<string, any>, {}, "">;
|
|
30
|
+
type $$ComponentProps = {
|
|
31
|
+
markers?: any[];
|
|
32
|
+
center?: any[];
|
|
33
|
+
zoom?: number;
|
|
34
|
+
distance?: number;
|
|
35
|
+
tileSource?: Record<string, any>;
|
|
36
|
+
onclick?: any;
|
|
37
|
+
height?: string;
|
|
38
|
+
class?: string;
|
|
39
|
+
} & Record<string, any>;
|