@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.
- package/dist/elements.css +119 -0
- package/dist/elements.iife.js +2 -2
- package/dist/elements.js +115 -114
- package/dist/full.css +119 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +8175 -5443
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/RingProgress.d.ts +2 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/package.json +1 -1
- package/src/index.ts +36 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +1 -3
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +286 -0
- package/src/ui/GanttChart.tsx +9 -1
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +1 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +281 -0
- package/src/ui/RingProgress.tsx +3 -0
- package/src/ui/SignaturePad.tsx +221 -0
- 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
|
+
}
|
package/src/ui/GanttChart.tsx
CHANGED
|
@@ -155,7 +155,15 @@ export function GanttChart(props: GanttChartProps): JSX.Element {
|
|
|
155
155
|
})
|
|
156
156
|
|
|
157
157
|
return (
|
|
158
|
-
<div
|
|
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
|
+
}
|