@a4ui/core 0.34.0 → 0.35.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.
Files changed (42) hide show
  1. package/dist/elements.css +119 -0
  2. package/dist/elements.iife.js +2 -2
  3. package/dist/elements.js +115 -114
  4. package/dist/full.css +119 -0
  5. package/dist/index.d.ts +15 -1
  6. package/dist/index.js +8175 -5443
  7. package/dist/ui/ActivityFeed.d.ts +49 -0
  8. package/dist/ui/AvailabilityPicker.d.ts +35 -0
  9. package/dist/ui/CodeEditor.d.ts +25 -0
  10. package/dist/ui/EmojiPicker.d.ts +18 -0
  11. package/dist/ui/EventScheduler.d.ts +41 -0
  12. package/dist/ui/InteractiveMap.d.ts +50 -0
  13. package/dist/ui/JsonViewer.d.ts +24 -0
  14. package/dist/ui/LocationPicker.d.ts +25 -0
  15. package/dist/ui/MaskedInput.d.ts +26 -0
  16. package/dist/ui/OtpInput.d.ts +29 -0
  17. package/dist/ui/PresenceAvatars.d.ts +54 -0
  18. package/dist/ui/QueryBuilder.d.ts +56 -0
  19. package/dist/ui/RingProgress.d.ts +2 -0
  20. package/dist/ui/SignaturePad.d.ts +19 -0
  21. package/dist/ui/SpreadsheetGrid.d.ts +27 -0
  22. package/package.json +1 -1
  23. package/src/index.ts +36 -1
  24. package/src/ui/ActivityFeed.tsx +178 -0
  25. package/src/ui/AudioWaveform.tsx +1 -3
  26. package/src/ui/AvailabilityPicker.tsx +163 -0
  27. package/src/ui/CodeEditor.tsx +277 -0
  28. package/src/ui/EmojiPicker.tsx +424 -0
  29. package/src/ui/EventScheduler.tsx +286 -0
  30. package/src/ui/GanttChart.tsx +9 -1
  31. package/src/ui/InteractiveMap.tsx +349 -0
  32. package/src/ui/JsonViewer.tsx +189 -0
  33. package/src/ui/LocationPicker.tsx +96 -0
  34. package/src/ui/MaskedInput.tsx +108 -0
  35. package/src/ui/OnboardingChecklist.tsx +1 -0
  36. package/src/ui/OtpInput.tsx +153 -0
  37. package/src/ui/PivotTable.tsx +0 -0
  38. package/src/ui/PresenceAvatars.tsx +142 -0
  39. package/src/ui/QueryBuilder.tsx +281 -0
  40. package/src/ui/RingProgress.tsx +3 -0
  41. package/src/ui/SignaturePad.tsx +221 -0
  42. package/src/ui/SpreadsheetGrid.tsx +307 -0
@@ -0,0 +1,286 @@
1
+ // Time-grid calendar: day or week view of hour bands (startHour..endHour) with
2
+ // events absolutely positioned by their start/end minutes, split side-by-side on
3
+ // overlap. Shares its time-slot idiom with AvailabilityPicker (native `Date`,
4
+ // ISO/'HH:mm' strings, no date library) — this one draws a positioned grid
5
+ // instead of a button list. Plain Solid + theme tokens (works in light/dark).
6
+ import type { JSX } from 'solid-js'
7
+ import { createMemo, For, Show } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+
11
+ /** A single scheduled event; `start`/`end` are local ISO datetimes ('YYYY-MM-DDTHH:mm'). */
12
+ export interface SchedulerEvent {
13
+ id: string
14
+ title: string
15
+ start: string
16
+ end: string
17
+ tone?: 'primary' | 'accent'
18
+ }
19
+
20
+ export interface EventSchedulerProps {
21
+ events: SchedulerEvent[]
22
+ /** Day to show (day view) or the week containing it (week view). ISO 'YYYY-MM-DD'. @default today */
23
+ date?: string
24
+ /** @default 'day' */
25
+ view?: 'day' | 'week'
26
+ /** First hour band shown (0–23). @default 7 */
27
+ startHour?: number
28
+ /** Last hour band shown, exclusive (0–23). @default 21 */
29
+ endHour?: number
30
+ class?: string
31
+ }
32
+
33
+ const HOUR_PX = 56
34
+ const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
35
+
36
+ const TONE_CLASSES: Record<'primary' | 'accent', string> = {
37
+ primary: 'bg-primary text-primary-foreground',
38
+ accent: 'bg-accent text-accent-foreground',
39
+ }
40
+
41
+ /** Local-time `YYYY-MM-DD` key for a Date. */
42
+ function isoKey(d: Date): string {
43
+ const y = d.getFullYear()
44
+ const m = String(d.getMonth() + 1).padStart(2, '0')
45
+ const day = String(d.getDate()).padStart(2, '0')
46
+ return `${y}-${m}-${day}`
47
+ }
48
+
49
+ /** Parse a local ISO datetime ('YYYY-MM-DDTHH:mm') into a `Date`, no timezone shifting. */
50
+ function parseLocal(dt: string): Date {
51
+ const [datePart, timePart = '00:00'] = dt.split('T')
52
+ const [y, m, d] = datePart.split('-').map(Number)
53
+ const [hh, mm] = timePart.split(':').map(Number)
54
+ return new Date(y, m - 1, d, hh, mm)
55
+ }
56
+
57
+ function minutesOfDay(d: Date): number {
58
+ return d.getHours() * 60 + d.getMinutes()
59
+ }
60
+
61
+ /** 'HH:mm'-since-midnight minutes rendered as a 12h clock label, e.g. 570 -> '9:30 AM'. */
62
+ function fmtTime(min: number): string {
63
+ const h = Math.floor(min / 60)
64
+ const m = min % 60
65
+ const period = h < 12 ? 'AM' : 'PM'
66
+ const h12 = h % 12 === 0 ? 12 : h % 12
67
+ return `${h12}:${String(m).padStart(2, '0')} ${period}`
68
+ }
69
+
70
+ interface PositionedEvent {
71
+ event: SchedulerEvent
72
+ startMin: number
73
+ endMin: number
74
+ }
75
+
76
+ interface LaidOutEvent extends PositionedEvent {
77
+ col: number
78
+ colCount: number
79
+ }
80
+
81
+ /**
82
+ * Greedy interval-graph coloring: overlapping events in a day column get
83
+ * side-by-side sub-columns (like most calendar UIs). Non-overlapping events
84
+ * each get the full column width.
85
+ */
86
+ function layoutColumn(events: PositionedEvent[]): LaidOutEvent[] {
87
+ const sorted = [...events].sort((a, b) => a.startMin - b.startMin || a.endMin - b.endMin)
88
+ const results: LaidOutEvent[] = []
89
+ let cluster: (PositionedEvent & { col: number })[] = []
90
+ let colEnds: number[] = []
91
+ let clusterMaxEnd = -Infinity
92
+
93
+ const flush = () => {
94
+ if (cluster.length === 0) return
95
+ const colCount = Math.max(...cluster.map((e) => e.col)) + 1
96
+ for (const e of cluster) results.push({ ...e, colCount })
97
+ cluster = []
98
+ }
99
+
100
+ for (const ev of sorted) {
101
+ if (ev.startMin >= clusterMaxEnd) {
102
+ flush()
103
+ colEnds = []
104
+ clusterMaxEnd = -Infinity
105
+ }
106
+ let col = colEnds.findIndex((end) => end <= ev.startMin)
107
+ if (col === -1) {
108
+ col = colEnds.length
109
+ colEnds.push(ev.endMin)
110
+ } else {
111
+ colEnds[col] = ev.endMin
112
+ }
113
+ clusterMaxEnd = Math.max(clusterMaxEnd, ev.endMin)
114
+ cluster.push({ ...ev, col })
115
+ }
116
+ flush()
117
+ return results
118
+ }
119
+
120
+ /**
121
+ * Time-grid calendar: one column per visible day, hour bands from `startHour` to
122
+ * `endHour`, events absolutely positioned by their start/end minutes and split
123
+ * side-by-side when they overlap. Draws a "now" line through today's column
124
+ * when it's in view. Read-only positioning (drag-to-create/resize is out of
125
+ * scope). Scrolls vertically when the hour range is tall.
126
+ *
127
+ * @example
128
+ * ```tsx
129
+ * <EventScheduler
130
+ * view="week"
131
+ * date="2026-07-21"
132
+ * events={[
133
+ * { id: '1', title: 'Standup', start: '2026-07-21T09:00', end: '2026-07-21T09:30', tone: 'primary' },
134
+ * { id: '2', title: 'Design review', start: '2026-07-21T09:15', end: '2026-07-21T10:00', tone: 'accent' },
135
+ * ]}
136
+ * />
137
+ * ```
138
+ */
139
+ export function EventScheduler(props: EventSchedulerProps): JSX.Element {
140
+ const startHour = () => props.startHour ?? 7
141
+ const endHour = () => props.endHour ?? 21
142
+ const hours = createMemo(() => {
143
+ const out: number[] = []
144
+ for (let h = startHour(); h < endHour(); h++) out.push(h)
145
+ return out
146
+ })
147
+ const gridHeight = () => (endHour() - startHour()) * HOUR_PX
148
+ const rangeStart = () => startHour() * 60
149
+ const rangeEnd = () => endHour() * 60
150
+ const pxFor = (min: number) => ((min - rangeStart()) / (rangeEnd() - rangeStart())) * gridHeight()
151
+
152
+ const baseDate = createMemo(() => (props.date ? parseLocal(props.date) : new Date()))
153
+
154
+ const days = createMemo(() => {
155
+ const base = baseDate()
156
+ if ((props.view ?? 'day') === 'day') {
157
+ return [new Date(base.getFullYear(), base.getMonth(), base.getDate())]
158
+ }
159
+ const sunday = new Date(base.getFullYear(), base.getMonth(), base.getDate() - base.getDay())
160
+ return Array.from(
161
+ { length: 7 },
162
+ (_, i) => new Date(sunday.getFullYear(), sunday.getMonth(), sunday.getDate() + i),
163
+ )
164
+ })
165
+
166
+ const eventsByDay = createMemo(() => {
167
+ const map = new Map<string, PositionedEvent[]>()
168
+ for (const event of props.events) {
169
+ const start = parseLocal(event.start)
170
+ const end = parseLocal(event.end)
171
+ const key = isoKey(start)
172
+ const startMin = minutesOfDay(start)
173
+ const list = map.get(key) ?? []
174
+ list.push({ event, startMin, endMin: Math.max(minutesOfDay(end), startMin + 15) })
175
+ map.set(key, list)
176
+ }
177
+ return map
178
+ })
179
+
180
+ const today = new Date()
181
+ const nowMin = today.getHours() * 60 + today.getMinutes()
182
+
183
+ return (
184
+ <div class={cn('flex flex-col overflow-hidden rounded-xl border border-border bg-card', props.class)}>
185
+ {/* Header row: gutter + one label per visible day */}
186
+ <div class="flex border-b border-border">
187
+ <div class="w-14 shrink-0" />
188
+ <For each={days()}>
189
+ {(day) => (
190
+ <div class="flex-1 border-l border-border py-2 text-center">
191
+ <div class="text-xs text-muted-foreground">{DAY_LABELS[day.getDay()]}</div>
192
+ <div
193
+ class={cn(
194
+ 'mx-auto mt-0.5 grid h-6 w-6 place-items-center rounded-full text-sm text-foreground',
195
+ isoKey(day) === isoKey(today) && 'bg-primary font-semibold text-primary-foreground',
196
+ )}
197
+ >
198
+ {day.getDate()}
199
+ </div>
200
+ </div>
201
+ )}
202
+ </For>
203
+ </div>
204
+
205
+ {/* Body: scrolls vertically; gutter of hour labels + day columns */}
206
+ <div
207
+ role="group"
208
+ aria-label="Schedule grid"
209
+ tabIndex={0}
210
+ class="flex overflow-y-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
211
+ style={{ 'max-height': '32rem' }}
212
+ >
213
+ <div class="w-14 shrink-0" style={{ height: `${gridHeight()}px` }}>
214
+ <For each={hours()}>
215
+ {(h) => (
216
+ <div
217
+ class="relative text-right text-xs text-muted-foreground"
218
+ style={{ height: `${HOUR_PX}px` }}
219
+ >
220
+ <span class="absolute -top-2 right-2">{fmtTime(h * 60)}</span>
221
+ </div>
222
+ )}
223
+ </For>
224
+ </div>
225
+
226
+ <For each={days()}>
227
+ {(day) => {
228
+ const laidOut = createMemo(() => layoutColumn(eventsByDay().get(isoKey(day)) ?? []))
229
+ const isToday = isoKey(day) === isoKey(today)
230
+ return (
231
+ <div class="relative flex-1 border-l border-border" style={{ height: `${gridHeight()}px` }}>
232
+ {/* Hour gridlines */}
233
+ <For each={hours()}>
234
+ {(h) => (
235
+ <div
236
+ class="absolute inset-x-0 border-t border-border"
237
+ style={{ top: `${pxFor(h * 60)}px` }}
238
+ />
239
+ )}
240
+ </For>
241
+
242
+ {/* "Now" line, today's column only */}
243
+ <Show when={isToday && nowMin >= rangeStart() && nowMin <= rangeEnd()}>
244
+ <div class="absolute inset-x-0 z-20 h-px bg-accent" style={{ top: `${pxFor(nowMin)}px` }}>
245
+ <span class="absolute -left-1 -top-1 h-2 w-2 rounded-full bg-accent" />
246
+ </div>
247
+ </Show>
248
+
249
+ {/* Events */}
250
+ <For each={laidOut()}>
251
+ {(item) => {
252
+ const clampedStart = Math.max(item.startMin, rangeStart())
253
+ const clampedEnd = Math.min(item.endMin, rangeEnd())
254
+ const top = pxFor(clampedStart)
255
+ const height = Math.max(pxFor(clampedEnd) - top, 18)
256
+ const widthPct = 100 / item.colCount
257
+ return (
258
+ <Show when={clampedEnd > rangeStart() && clampedStart < rangeEnd()}>
259
+ <div
260
+ class={cn(
261
+ 'absolute z-10 overflow-hidden rounded-md px-1.5 py-1 text-left text-xs shadow-sm',
262
+ TONE_CLASSES[item.event.tone ?? 'primary'],
263
+ )}
264
+ style={{
265
+ top: `${top}px`,
266
+ height: `${height}px`,
267
+ left: `${item.col * widthPct}%`,
268
+ width: `calc(${widthPct}% - 2px)`,
269
+ }}
270
+ title={`${item.event.title} · ${fmtTime(item.startMin)}–${fmtTime(item.endMin)}`}
271
+ >
272
+ <div class="truncate font-medium">{item.event.title}</div>
273
+ <div class="truncate opacity-80">{fmtTime(item.startMin)}</div>
274
+ </div>
275
+ </Show>
276
+ )
277
+ }}
278
+ </For>
279
+ </div>
280
+ )
281
+ }}
282
+ </For>
283
+ </div>
284
+ </div>
285
+ )
286
+ }
@@ -155,7 +155,15 @@ export function GanttChart(props: GanttChartProps): JSX.Element {
155
155
  })
156
156
 
157
157
  return (
158
- <div class={cn('overflow-x-auto rounded-lg border border-border bg-card', props.class)}>
158
+ <div
159
+ role="group"
160
+ aria-label="Gantt chart"
161
+ tabIndex={0}
162
+ class={cn(
163
+ 'overflow-x-auto rounded-lg border border-border bg-card outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
164
+ props.class,
165
+ )}
166
+ >
159
167
  <Show
160
168
  when={props.tasks.length > 0}
161
169
  fallback={<p class="p-4 text-sm text-muted-foreground">No tasks to display.</p>}
@@ -0,0 +1,349 @@
1
+ // Slippy tile map from scratch — no leaflet/mapbox-gl. Standard Web Mercator
2
+ // (EPSG:3857) tile math: latLngToWorldPixel/worldPixelToLatLng convert between
3
+ // lat/lng and "world pixel" space at a given integer zoom (world size =
4
+ // 256 * 2^zoom px). The visible tile range is derived from the container size
5
+ // and the world-pixel position of the top-left corner; each tile renders as an
6
+ // absolutely-positioned <img>, repositioned (not reloaded) as the user pans.
7
+ import { Minus, Plus, MapPin } from 'lucide-solid'
8
+ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, untrack, type JSX } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { motionReduced } from '../lib/motion'
12
+ import { Button } from './Button'
13
+
14
+ export interface MapMarker {
15
+ lat: number
16
+ lng: number
17
+ label?: string
18
+ id?: string
19
+ }
20
+
21
+ export interface InteractiveMapProps {
22
+ /** Initial view center. Also acts as a recenter trigger: passing a new
23
+ * `{ lat, lng }` after mount pans the view there (e.g. a "use my
24
+ * location" button) without fighting the user's own drag/zoom in between.
25
+ * @default { lat: 0, lng: 0 } */
26
+ center?: { lat: number; lng: number }
27
+ /** Initial integer zoom level, clamped to `[2, 18]`. Synced the same way
28
+ * as `center` when it changes after mount. @default 3 */
29
+ zoom?: number
30
+ /** Pins rendered on top of the tiles, projected to their pixel position. */
31
+ markers?: MapMarker[]
32
+ /** Tile URL template with `{z}`/`{x}`/`{y}` placeholders.
33
+ * @default 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' */
34
+ tileUrl?: string
35
+ /** Height of the map, in px. @default 400 */
36
+ height?: number
37
+ /** Fires with the lat/lng under the pointer on a click (drags don't count). */
38
+ onClick?: (coord: { lat: number; lng: number }) => void
39
+ class?: string
40
+ }
41
+
42
+ const TILE_SIZE = 256
43
+ const MIN_ZOOM = 2
44
+ const MAX_ZOOM = 18
45
+ const MAX_LAT = 85.05112878 // Web Mercator's latitude limit (where the projection would reach infinity)
46
+ const DEFAULT_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
47
+ const DRAG_THRESHOLD_PX = 4 // pointer movement below this still counts as a click, not a pan
48
+
49
+ interface WorldPixel {
50
+ x: number
51
+ y: number
52
+ }
53
+
54
+ interface TileDescriptor {
55
+ id: string
56
+ left: number
57
+ top: number
58
+ src: string
59
+ }
60
+
61
+ interface MarkerPosition {
62
+ marker: MapMarker
63
+ x: number
64
+ y: number
65
+ }
66
+
67
+ function clampZoom(z: number): number {
68
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(z)))
69
+ }
70
+
71
+ function clampLat(lat: number): number {
72
+ return Math.min(MAX_LAT, Math.max(-MAX_LAT, lat))
73
+ }
74
+
75
+ function wrapLng(lng: number): number {
76
+ return ((((lng + 180) % 360) + 360) % 360) - 180
77
+ }
78
+
79
+ /**
80
+ * Projects a lat/lng to "world pixel" space at an integer zoom (the pixel
81
+ * grid the size of the whole rendered world map, `256 * 2^zoom` square).
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * latLngToWorldPixel(0, 0, 0) // → { x: 128, y: 128 } (center of a single 256px tile)
86
+ * ```
87
+ */
88
+ function latLngToWorldPixel(lat: number, lng: number, zoom: number): WorldPixel {
89
+ const scale = TILE_SIZE * 2 ** zoom
90
+ const x = ((wrapLng(lng) + 180) / 360) * scale
91
+ const sinLat = Math.sin((clampLat(lat) * Math.PI) / 180)
92
+ const y = (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale
93
+ return { x, y }
94
+ }
95
+
96
+ /** Inverse of {@link latLngToWorldPixel}: world pixel + zoom → lat/lng. */
97
+ function worldPixelToLatLng(x: number, y: number, zoom: number): { lat: number; lng: number } {
98
+ const scale = TILE_SIZE * 2 ** zoom
99
+ const lng = (x / scale) * 360 - 180
100
+ const n = Math.PI - (2 * Math.PI * y) / scale
101
+ const lat = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
102
+ return { lat: clampLat(lat), lng: wrapLng(lng) }
103
+ }
104
+
105
+ function buildTileUrl(template: string, z: number, x: number, y: number): string {
106
+ return template.replace(/\{z\}/g, String(z)).replace(/\{x\}/g, String(x)).replace(/\{y\}/g, String(y))
107
+ }
108
+
109
+ /**
110
+ * A slippy tile map built directly on Web Mercator math — no map SDK. Pan by
111
+ * dragging, zoom with the +/- buttons or the wheel (zoom keeps the point
112
+ * under the cursor fixed), place `markers`, and get the lat/lng of a click
113
+ * via `onClick`. Recomputes its tile grid on resize (`ResizeObserver`).
114
+ *
115
+ * @example
116
+ * ```tsx
117
+ * <InteractiveMap
118
+ * center={{ lat: 48.8584, lng: 2.2945 }}
119
+ * zoom={13}
120
+ * markers={[{ lat: 48.8584, lng: 2.2945, label: 'Eiffel Tower' }]}
121
+ * onClick={(coord) => console.log('clicked', coord)}
122
+ * />
123
+ * ```
124
+ */
125
+ export function InteractiveMap(props: InteractiveMapProps): JSX.Element {
126
+ let containerRef: HTMLDivElement | undefined
127
+
128
+ const [size, setSize] = createSignal({ w: 0, h: 0 })
129
+ const [zoom, setZoom] = createSignal(clampZoom(props.zoom ?? 3))
130
+ const [center, setCenter] = createSignal(props.center ?? { lat: 0, lng: 0 })
131
+
132
+ // `center`/`zoom` are initial values that also act as recenter triggers —
133
+ // syncing only on a genuine value change (not every render) so they don't
134
+ // fight the user's own drag/zoom between prop updates.
135
+ let lastCenterProp = props.center
136
+ createEffect(() => {
137
+ const next = props.center
138
+ const prevApplied = untrack(center)
139
+ if (next && next !== lastCenterProp && (next.lat !== prevApplied.lat || next.lng !== prevApplied.lng)) {
140
+ setCenter({ lat: next.lat, lng: next.lng })
141
+ }
142
+ lastCenterProp = next
143
+ })
144
+
145
+ let lastZoomProp = props.zoom
146
+ createEffect(() => {
147
+ const next = props.zoom
148
+ if (next !== undefined && next !== lastZoomProp) setZoom(clampZoom(next))
149
+ lastZoomProp = next
150
+ })
151
+
152
+ onMount(() => {
153
+ if (!containerRef) return
154
+ const measure = (): void => {
155
+ if (!containerRef) return
156
+ const rect = containerRef.getBoundingClientRect()
157
+ setSize({ w: rect.width, h: rect.height })
158
+ }
159
+ measure()
160
+
161
+ let observer: ResizeObserver | undefined
162
+ if (typeof ResizeObserver !== 'undefined') {
163
+ observer = new ResizeObserver(measure)
164
+ observer.observe(containerRef)
165
+ }
166
+ onCleanup(() => observer?.disconnect())
167
+ })
168
+
169
+ // World-pixel position of the container's top-left corner — the origin
170
+ // every tile/marker position is measured against.
171
+ const topLeftPx = createMemo<WorldPixel>(() => {
172
+ const c = latLngToWorldPixel(center().lat, center().lng, zoom())
173
+ const { w, h } = size()
174
+ return { x: c.x - w / 2, y: c.y - h / 2 }
175
+ })
176
+
177
+ const tiles = createMemo<TileDescriptor[]>(() => {
178
+ const { w, h } = size()
179
+ if (w <= 0 || h <= 0) return []
180
+ const z = zoom()
181
+ const numTiles = 2 ** z
182
+ const tl = topLeftPx()
183
+ const url = props.tileUrl ?? DEFAULT_TILE_URL
184
+
185
+ const minX = Math.floor(tl.x / TILE_SIZE) - 1
186
+ const maxX = Math.floor((tl.x + w) / TILE_SIZE) + 1
187
+ const minY = Math.max(0, Math.floor(tl.y / TILE_SIZE) - 1)
188
+ const maxY = Math.min(numTiles - 1, Math.floor((tl.y + h) / TILE_SIZE) + 1)
189
+
190
+ const list: TileDescriptor[] = []
191
+ for (let x = minX; x <= maxX; x++) {
192
+ const wrappedX = ((x % numTiles) + numTiles) % numTiles
193
+ for (let y = minY; y <= maxY; y++) {
194
+ list.push({
195
+ id: `${z}/${x}/${y}`,
196
+ left: x * TILE_SIZE - tl.x,
197
+ top: y * TILE_SIZE - tl.y,
198
+ src: buildTileUrl(url, z, wrappedX, y),
199
+ })
200
+ }
201
+ }
202
+ return list
203
+ })
204
+
205
+ const markerPositions = createMemo<MarkerPosition[]>(() => {
206
+ const tl = topLeftPx()
207
+ const z = zoom()
208
+ return (props.markers ?? []).map((marker) => {
209
+ const px = latLngToWorldPixel(marker.lat, marker.lng, z)
210
+ return { marker, x: px.x - tl.x, y: px.y - tl.y }
211
+ })
212
+ })
213
+
214
+ const clientToLatLng = (clientX: number, clientY: number): { lat: number; lng: number } | null => {
215
+ if (!containerRef) return null
216
+ const rect = containerRef.getBoundingClientRect()
217
+ const tl = topLeftPx()
218
+ return worldPixelToLatLng(tl.x + (clientX - rect.left), tl.y + (clientY - rect.top), zoom())
219
+ }
220
+
221
+ // --- pan (pointer drag) ----------------------------------------------
222
+ let removeDragListeners: (() => void) | null = null
223
+
224
+ const handlePointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }): void => {
225
+ const target = event.currentTarget
226
+ target.setPointerCapture(event.pointerId)
227
+
228
+ const drag = {
229
+ startClientX: event.clientX,
230
+ startClientY: event.clientY,
231
+ startCenterPx: latLngToWorldPixel(center().lat, center().lng, zoom()),
232
+ moved: false,
233
+ }
234
+
235
+ const handleMove = (moveEvent: PointerEvent): void => {
236
+ const dx = moveEvent.clientX - drag.startClientX
237
+ const dy = moveEvent.clientY - drag.startClientY
238
+ if (!drag.moved && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) drag.moved = true
239
+ if (!drag.moved) return
240
+ const z = zoom()
241
+ setCenter(worldPixelToLatLng(drag.startCenterPx.x - dx, drag.startCenterPx.y - dy, z))
242
+ }
243
+
244
+ const release = (upEvent: PointerEvent): void => {
245
+ removeDragListeners?.()
246
+ if (!drag.moved) {
247
+ const coord = clientToLatLng(upEvent.clientX, upEvent.clientY)
248
+ if (coord) props.onClick?.(coord)
249
+ }
250
+ }
251
+
252
+ target.addEventListener('pointermove', handleMove)
253
+ target.addEventListener('pointerup', release)
254
+ target.addEventListener('lostpointercapture', release)
255
+ removeDragListeners = () => {
256
+ target.removeEventListener('pointermove', handleMove)
257
+ target.removeEventListener('pointerup', release)
258
+ target.removeEventListener('lostpointercapture', release)
259
+ removeDragListeners = null
260
+ }
261
+ }
262
+
263
+ onCleanup(() => removeDragListeners?.())
264
+
265
+ // --- zoom (wheel, keeping the point under the cursor fixed) ----------
266
+ const handleWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }): void => {
267
+ event.preventDefault()
268
+ const oldZoom = zoom()
269
+ const newZoom = clampZoom(oldZoom + (event.deltaY < 0 ? 1 : -1))
270
+ if (newZoom === oldZoom) return
271
+
272
+ const rect = event.currentTarget.getBoundingClientRect()
273
+ const px = event.clientX - rect.left
274
+ const py = event.clientY - rect.top
275
+ const tl = topLeftPx()
276
+ const cursorLatLng = worldPixelToLatLng(tl.x + px, tl.y + py, oldZoom)
277
+ const cursorAtNewZoom = latLngToWorldPixel(cursorLatLng.lat, cursorLatLng.lng, newZoom)
278
+ const { w, h } = size()
279
+
280
+ setZoom(newZoom)
281
+ setCenter(worldPixelToLatLng(cursorAtNewZoom.x - px + w / 2, cursorAtNewZoom.y - py + h / 2, newZoom))
282
+ }
283
+
284
+ const zoomBy = (delta: number): void => {
285
+ setZoom((z) => clampZoom(z + delta))
286
+ }
287
+
288
+ return (
289
+ <div
290
+ class={cn('relative overflow-hidden rounded-lg border border-border bg-muted', props.class)}
291
+ style={{ height: `${props.height ?? 400}px` }}
292
+ >
293
+ <div
294
+ ref={containerRef}
295
+ class="absolute inset-0 touch-none cursor-grab select-none active:cursor-grabbing"
296
+ role="application"
297
+ aria-label="Interactive map"
298
+ onPointerDown={handlePointerDown}
299
+ onWheel={handleWheel}
300
+ >
301
+ <For each={tiles()}>
302
+ {(tile) => (
303
+ <img
304
+ src={tile.src}
305
+ alt=""
306
+ draggable={false}
307
+ class={cn(
308
+ 'absolute h-[256px] w-[256px] max-w-none select-none',
309
+ !motionReduced() &&
310
+ 'opacity-0 transition-opacity duration-150 [&.a4ui-tile-loaded]:opacity-100',
311
+ )}
312
+ style={{ left: `${tile.left}px`, top: `${tile.top}px` }}
313
+ onLoad={(event) => event.currentTarget.classList.add('a4ui-tile-loaded')}
314
+ />
315
+ )}
316
+ </For>
317
+ <For each={markerPositions()}>
318
+ {(mp) => (
319
+ <div
320
+ class="pointer-events-auto absolute -translate-x-1/2 -translate-y-full"
321
+ style={{ left: `${mp.x}px`, top: `${mp.y}px` }}
322
+ title={mp.marker.label}
323
+ >
324
+ <MapPin class="h-7 w-7 text-primary drop-shadow" aria-label={mp.marker.label ?? 'Marker'} />
325
+ </div>
326
+ )}
327
+ </For>
328
+ </div>
329
+
330
+ <div class="absolute top-2 right-2 flex flex-col gap-1">
331
+ <Button variant="secondary" class="h-8 w-8 p-0" aria-label="Zoom in" onClick={() => zoomBy(1)}>
332
+ <Plus class="h-4 w-4" />
333
+ </Button>
334
+ <Button variant="secondary" class="h-8 w-8 p-0" aria-label="Zoom out" onClick={() => zoomBy(-1)}>
335
+ <Minus class="h-4 w-4" />
336
+ </Button>
337
+ </div>
338
+
339
+ <a
340
+ href="https://www.openstreetmap.org/copyright"
341
+ target="_blank"
342
+ rel="noreferrer"
343
+ class="absolute bottom-0.5 left-1 rounded bg-card/70 px-1 text-[10px] text-muted-foreground backdrop-blur-sm hover:text-foreground"
344
+ >
345
+ © OpenStreetMap
346
+ </a>
347
+ </div>
348
+ )
349
+ }