@close-by/clay 0.2.18 → 0.2.19

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/docs/README.md CHANGED
@@ -22,6 +22,7 @@ For coding agents: the repo-root [llms.txt](../llms.txt) is a short map of these
22
22
  | [Ops patterns](./ops-patterns.md) | Dense consoles: chips, master–detail, feeds, sensitive logs, auth nav |
23
23
  | [API reference](./api.md) | `ui.*`, `Element` methods, `reactive`, helpers |
24
24
  | [Elements](./elements.md) | All element types and props |
25
+ | [Maps](./maps.md) | `ui.map`: MapLibre markers, routes, arcs, GeoJSON, clusters; palette theming and in-place updates |
25
26
  | [WebSocket protocol](./protocol.md) | Client ↔ server message formats |
26
27
  | [Architecture](./architecture.md) | Packages, data flow, and ownership |
27
28
  | [Examples](./examples.md) | Demo apps and patterns |
package/docs/api.md CHANGED
@@ -91,6 +91,7 @@ ui.run({
91
91
  | `title` | `string` | `'Clay'` | HTML `<title>` |
92
92
  | `clientDir` | `string` | `resolveClayClientDir()` | Built Vite assets (`@close-by/clay-cli/client-dist` when installed). Prefer `resolveClayClientDir()` over hardcoding `node_modules` paths — see [Boot](./boot.md). |
93
93
  | `css` | `string \| string[]` | | Extra CSS file path(s) served after the client bundle (absolute or cwd-relative). Override theme tokens without rebuilding the client. |
94
+ | `sound` | `boolean` | | Play short synthesized interface sounds by default (`false`). See [Sound](#sound). |
94
95
  | `app` | `AppProps` | | Global dashboard shell; client keeps chrome sticky across navigate |
95
96
  | `authSecret` | `string` | | Enables signed auth cookies (`POST`/`DELETE /auth/session`), `ui.establishAuthSession` / `ui.clearAuthSession`, and default `resolveUserId` from the cookie |
96
97
  | `authCookieMaxAgeSec` | `number` | `43200` (12h) | Auth cookie Max-Age |
@@ -1804,6 +1805,10 @@ onClick: async () => {
1804
1805
  | `ui.theme.palette(name)` | `void` | Switch colour palette for this client (`kiln`, `cobalt`, `ocean`, `forest`, `orchid`, `citrus`, `graphite`, `neon`, `sky`, `navy`, `indigo`, `denim`, `midnight`, or a custom one); remembered across reloads |
1805
1806
  | `ui.theme.getPalette()` | `ThemePalette \| null` | Last palette set, else the one the client reported on connect |
1806
1807
  | `ui.theme.palettes` | `readonly string[]` | Built-in palette names |
1808
+ | `ui.sound.set(on)` | `void` | Turn automatic interface sounds on/off for this client; remembered across reloads. See [Sound](#sound) |
1809
+ | `ui.sound.volume(v)` | `void` | Master volume `0`–`1` (default `0.5`); remembered across reloads |
1810
+ | `ui.sound.play(name)` | `void` | Play one cue now, even when automatic sounds are off |
1811
+ | `ui.sound.names` | `readonly string[]` | Built-in cue names |
1807
1812
  | `ui.runJavaScript(code)` | `void` | Eval trusted snippet in the browser |
1808
1813
  | `ui.scroll.to(opts)` | `void` | Scroll window (`top` / `bottom` / px) |
1809
1814
  | `ui.scroll.intoView(selector, opts?)` | `void` | `Element.scrollIntoView` via selector |
@@ -1918,6 +1923,7 @@ All layout helpers accept either `(fn, props?)` or `(props, fn)`.
1918
1923
  | `ui.hero` | Centered hero region |
1919
1924
  | `ui.card` | Card; callback receives the card element |
1920
1925
  | `ui.app` | SPA shell: sidebar nav + centered main content |
1926
+ | `ui.map` | Live map (MapLibre): markers, routes, arcs, GeoJSON, clusters, popup. See [Maps](./maps.md) |
1921
1927
 
1922
1928
  #### `ui.app(props, fn)`
1923
1929
 
@@ -2016,6 +2022,15 @@ ui.container(() => {
2016
2022
  }, { centered: true, width: 'md' });
2017
2023
  ```
2018
2024
 
2025
+ #### `ui.map(props?)`
2026
+
2027
+ A live map: MapLibre with markers, routes, arcs, GeoJSON layers, clusters and a popup. Setters (`setMarkers`, `upsertMarker`, `updateRoute`, `flyTo`, `fitBounds`, ...) update it in place. Props, tones, events and layout are in the [Maps guide](./maps.md).
2028
+
2029
+ ```typescript
2030
+ const fleet = ui.map({ fill: true, markers: vehicles(), onMarkerClick: (m) => select(m.id) });
2031
+ fleet.fitBounds('markers', { padding: 48 });
2032
+ ```
2033
+
2019
2034
  ---
2020
2035
 
2021
2036
  ## `Element` methods (`@close-by/clay-core`)
@@ -2183,6 +2198,33 @@ import { button, input /* … */ } from '@close-by/clay-components';
2183
2198
 
2184
2199
  ---
2185
2200
 
2201
+ ## Sound
2202
+
2203
+ Clay can answer interactions with short interface sounds, synthesized in the browser with Web Audio (no audio files). They're **off by default**. Turn them on for every visitor with `createServer({ sound: true })`, or per session with `ui.sound.set(true)`. The client remembers the last choice.
2204
+
2205
+ Once sound is on, a mute button appears next to the Clay connection chip in the bottom-left corner, so each user can silence it. A user's mute choice is remembered for their browser. Apps that never turn sound on don't show the button.
2206
+
2207
+ When enabled, a page-wide listener picks the cue from the element, so components need no wiring:
2208
+
2209
+ | Interaction | Cue |
2210
+ |---|---|
2211
+ | Button, link, `role="button"` | `tap` (`soft` for `ghost` / `link` variants, `destructive` for the destructive variant) |
2212
+ | Tab, menu item, option, radio | `select` (pitch rises with position) |
2213
+ | Switch, checkbox | `toggleOn` / `toggleOff` |
2214
+ | Disabled control (when it still receives the press) | `blocked` |
2215
+ | Dialog / sheet / alert dialog opens or closes | `open` / `close` |
2216
+ | Slider value changes | `tick` |
2217
+ | OTP digit typed | `key` |
2218
+ | `ui.notify` toast | `success` / `error` / `warning` / `notification` by type |
2219
+ | `ui.clipboard` / code block copy | `copy` |
2220
+
2221
+ Set `data-sound="<cue>"` on an element to override its cue, or `data-sound="none"` to silence it. Use `ui.sound.play(name)` to play a cue from server code, for example when a long job finishes. Cues: `tap`, `soft`, `select`, `destructive`, `blocked`, `toggleOn`, `toggleOff`, `open`, `close`, `tick`, `key`, `copy`, `success`, `error`, `warning`, `notification`.
2222
+
2223
+ ```ts
2224
+ // A settings switch that lets each user turn sounds on or off
2225
+ ui.switch({ label: 'Interface sounds', onChange: (on) => ui.sound.set(on) });
2226
+ ```
2227
+
2186
2228
  ## DuckDB (`@close-by/clay-duckdb`)
2187
2229
 
2188
2230
  See [DuckDB](./duckdb.md) for the multi-database wrapper (`connect`, `attach`, CRUD).
@@ -0,0 +1,64 @@
1
+ # Demo deployment on Pi
2
+
3
+ The demo runs at https://clay.close-by.org/ on Pi as the `bad-ui-demo` systemd service. It runs Bun as the `pi` user from `/home/pi/bad-ui-demo` on port 4000. The existing Flock Cloudflare tunnel forwards `clay.close-by.org` to `http://localhost:4000`; the Cloudflare DNS record is a proxied CNAME to that tunnel.
4
+
5
+ ## Update the demo
6
+
7
+ From the repository root on a machine with SSH access to the `pi` host, copy the working tree and rebuild on Pi:
8
+
9
+ ```sh
10
+ tar --exclude-vcs --exclude=node_modules --exclude=dist-pack \
11
+ --exclude=.env --exclude='.env.*' --exclude=.badui-user-data \
12
+ --exclude=.clay-user-data --exclude=.clay --exclude=.tmp \
13
+ -czf /tmp/clay-demo-deploy.tgz .
14
+ scp /tmp/clay-demo-deploy.tgz pi:/tmp/clay-demo-deploy.tgz
15
+ ssh pi 'tar -xzf /tmp/clay-demo-deploy.tgz -C ~/bad-ui-demo && \
16
+ cd ~/bad-ui-demo && export PATH="$HOME/.local/bin:$PATH" && \
17
+ bun install --frozen-lockfile && bun run build:client && \
18
+ sudo systemctl restart bad-ui-demo'
19
+ ```
20
+
21
+ This copies the current working tree, including uncommitted changes. Extraction does not delete files removed from the source tree; remove obsolete deployed files deliberately when needed. The service keeps running the previous process until the restart, but the copy and build happen in its working directory.
22
+
23
+ Check the service and public endpoint:
24
+
25
+ ```sh
26
+ ssh pi 'systemctl status bad-ui-demo --no-pager; journalctl -u bad-ui-demo -n 50 --no-pager'
27
+ curl -I https://clay.close-by.org/
28
+ ```
29
+
30
+ ## Service and tunnel setup
31
+
32
+ The service is enabled with `sudo systemctl enable --now bad-ui-demo`. Its unit is `/etc/systemd/system/bad-ui-demo.service`:
33
+
34
+ ```ini
35
+ [Unit]
36
+ Description=Clay demo
37
+ After=network.target
38
+
39
+ [Service]
40
+ User=pi
41
+ WorkingDirectory=/home/pi/bad-ui-demo
42
+ Environment=PORT=4000
43
+ Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
44
+ EnvironmentFile=/home/pi/.config/bad-ui-demo/service.env
45
+ ExecStart=/home/pi/.local/bin/bun apps/demo/src/main.ts
46
+ Restart=on-failure
47
+ RestartSec=2
48
+
49
+ [Install]
50
+ WantedBy=multi-user.target
51
+ ```
52
+
53
+ The environment file contains `CLAY_AUTH_SECRET`; keep it private and stable across restarts. To create it on a fresh deployment, use `umask 077`, create `/home/pi/.config/bad-ui-demo`, and write `CLAY_AUTH_SECRET=` followed by a random secret (for example, from `openssl rand -hex 32`). Changing it invalidates existing signed sessions. After changing a unit, run `sudo systemctl daemon-reload` and restart the service.
54
+
55
+ The tunnel configuration is `/etc/cloudflared/flock.yml`, managed by `flock-tunnel.service`. Its Clay ingress entry must precede the final catch-all:
56
+
57
+ ```yaml
58
+ - hostname: clay.close-by.org
59
+ service: http://localhost:4000
60
+ ```
61
+
62
+ Validate changes with `sudo cloudflared tunnel --config /etc/cloudflared/flock.yml ingress validate`, then restart `flock-tunnel`. This is shared with Flock, so preserve its existing ingress entries. The DNS CNAME must point to the existing tunnel's `<tunnel-id>.cfargotunnel.com` and be proxied; the tunnel credentials on Pi alone do not grant permission to edit Cloudflare DNS.
63
+
64
+ This is a public demo with example authentication and data, not a hardened production application. See [Production & security](./production.md) before deploying real data or applications.
package/docs/examples.md CHANGED
@@ -7,7 +7,7 @@ After `bun run build:client && bun run demo` (or `bun run demo:cli`), open http:
7
7
 
8
8
  ## Sidebar groups
9
9
 
10
- Each page sets `group` / `groupIcon` in its `pageMeta`, so the sidebar and the home page's catalog share one structure: **Start here** (themes, fill layout, counter, todo, kitchen sink) → **Reactivity** → **Data** → **Forms & controls** → **Overlays & layout** → **Apps** → **Account** → **ReUI batches**. Order within a group is `pageMeta.order`.
10
+ Each page sets `group` / `groupIcon` in its `pageMeta`, so the sidebar and the home page's catalog share one structure: **Start here** (themes, fill layout, counter, todo, kitchen sink) → **Reactivity** → **Data** → **Maps** (gallery, delivery tracker, store locator, globe, uptime monitor) → **Forms & controls** → **Overlays & layout** → **Apps** → **Account** → **ReUI batches**. Order within a group is `pageMeta.order`.
11
11
 
12
12
  ## Catalog
13
13
 
@@ -44,6 +44,11 @@ Each page sets `group` / `groupIcon` in its `pageMeta`, so the sidebar and the h
44
44
  | `/examples/gantt` | `GanttDemo.ts` | `ui.gantt` owned rows/dates/markers/deps, cross-row drag, header double-click markers, `itemMove` / `markerAdd` settle |
45
45
  | `/examples/flow` | `FlowDemo.ts` | Four `ui.flow` demos: ETL with in-node controls + labeled edges + dagre auto-layout, multi-handle approval branches, fan-in/out + `addNode` dynamic stages, group/`parentId` nesting (flow-owned edges/positions; no outer diagram `ui.auto`) |
46
46
  | `/examples/data-clients` | `DataClientsDemo.ts` | DuckDB / Kibana / ClickHouse integration story (mock-friendly) |
47
+ | `/examples/maps` | `MapsGallery.ts` | `ui.map` gallery: markers and popups, routes, arcs, choropleth, clusters, compact analytics map |
48
+ | `/examples/delivery-tracker` | `DeliveryTracker.ts` | `ui.map` route progress and courier marker: `updateRoute` / `upsertMarker` on a timer, `fill` layout |
49
+ | `/examples/store-locator` | `StoreLocator.ts` | `ui.map` filtered markers, `flyTo` / `fitBounds('markers')`, popups; list and map select each other by key |
50
+ | `/examples/globe` | `CommunityGlobe.ts` | `ui.map` globe projection, arcs, `setMarkers` / `setArcs` on a 30 s timer |
51
+ | `/examples/uptime` | `UptimeMonitor.ts` | `ui.map` status markers on a globe, refreshed every 2 s with `setMarkers` |
47
52
  | `/examples/kitchen-sink` | `KitchenSink.ts` | ShadCN catalog preview (client `KitchenSink`) |
48
53
 
49
54
  Each page file exports optional `pageMeta` (`label`, `icon`, `order`, optional `nav: false`, optional `roles`) for `ui.navFromPages()`.
package/docs/maps.md ADDED
@@ -0,0 +1,214 @@
1
+ # Maps
2
+
3
+ `ui.map(props)` is a live map with markers, routes, arcs, GeoJSON layers, clusters, heatmaps and a popup. It renders with [MapLibre GL](https://maplibre.org/) through a vendored copy of [mapcn](https://mapcn.vercel.app) (MIT). You create the map on the server, change it with setters, and handle clicks on the server.
4
+
5
+ - **Lazy.** The client loads the map code only when a page renders a map (`React.lazy` in the element renderer). Other pages do not download MapLibre.
6
+ - **Worker bundled.** The MapLibre worker ships with the client build. No CDN script is loaded.
7
+
8
+ ## Quick start
9
+
10
+ ```typescript
11
+ import { ui } from '@close-by/clay';
12
+
13
+ ui.page('/fleet', () => {
14
+ const fleet = ui.map({
15
+ center: [-9.14, 38.72],
16
+ zoom: 11,
17
+ height: '28rem',
18
+ markers: [
19
+ { id: 'hq', lngLat: [-9.142, 38.7108], label: 'HQ', popup: { title: 'Head office' } },
20
+ { id: 'truck-1', lngLat: [-9.1393, 38.7097], tone: 'chart-2', icon: 'truck', pulse: true },
21
+ ],
22
+ onMarkerClick: (marker) => ui.notify(`Selected ${marker.id}`),
23
+ });
24
+
25
+ fleet.fitBounds('markers', { padding: 48 });
26
+ });
27
+ ```
28
+
29
+ Coordinates are always `[longitude, latitude]`.
30
+
31
+ ## Theming
32
+
33
+ - **Light and dark.** The basemap follows the page theme. Markers, routes, arcs and fills take their colours from the active palette.
34
+ - **Tones.** Colour props accept a tone name or a CSS colour such as `#2563eb`:
35
+
36
+ | Tone | Palette token |
37
+ |------|---------------|
38
+ | `primary` | `--primary` (default for markers, routes and fills) |
39
+ | `chart-1` … `chart-5` | `--chart-1` … `--chart-5` |
40
+ | `destructive` | `--destructive` |
41
+ | `muted` | `--muted-foreground` |
42
+ | `foreground` | `--foreground` |
43
+
44
+ - **Live palette changes.** The map watches the root element's `class`, `data-palette` and `style`, and the system colour scheme. A palette change re-resolves the colours in place. The map is not remounted, so the camera and open popups stay put.
45
+ - **`tint`** (default `true`) lightly tints the basemap toward the palette: background, water, and parks or green areas.
46
+ - **`basemap`** (default `'auto'`) pins the basemap to `'light'` or `'dark'`, whatever the page theme.
47
+ - **`styles`** replaces the basemap style URLs: `{ light?: string; dark?: string }`. See [Tiles](#tiles).
48
+
49
+ ## Props
50
+
51
+ | Prop | Type | Default | Notes |
52
+ |------|------|---------|-------|
53
+ | `center` | `LngLat` | `[0, 20]` | Initial centre, read at mount. See [Camera](#camera) |
54
+ | `zoom` | `number` | `1.5` | Initial zoom |
55
+ | `pitch` / `bearing` | `number` | `0` | Initial pitch and bearing |
56
+ | `projection` | `'mercator' \| 'globe'` | `'mercator'` | |
57
+ | `height` | `string` | `'24rem'` | CSS length. Ignored with `fill` |
58
+ | `fill` | `boolean` | `false` | Fill the parent. See [Layout](#layout) |
59
+ | `interactive` | `boolean` | `true` | `false` disables pan, zoom and click |
60
+ | `basemap` | `'auto' \| 'light' \| 'dark'` | `'auto'` | |
61
+ | `styles` | `{ light?, dark? }` | CARTO styles | Custom MapLibre style URLs |
62
+ | `tint` | `boolean` | `true` | Palette tint on the basemap |
63
+ | `controls` | `MapControls \| false` | `{ zoom: true, position: 'bottom-right' }` | Object values merge over the default. `false` hides all controls |
64
+ | `markers` | `MapMarkerDatum[]` | `[]` | |
65
+ | `routes` | `MapRouteDatum[]` | `[]` | |
66
+ | `arcs` | `MapArcGroup[]` | `[]` | |
67
+ | `geojson` | `MapGeoJSONLayer[]` | `[]` | |
68
+ | `clusters` | `MapClusterGroup[]` | `[]` | |
69
+ | `heatmaps` | `MapHeatmapLayer[]` | `[]` | |
70
+ | `popup` | `{ lngLat, title?, description? } \| null` | closed | Standalone popup |
71
+
72
+ `MapControls` is `{ zoom?, compass?, locate?, fullscreen?, position? }`. `compass`, `locate` and `fullscreen` are off by default. `position` is `top-left`, `top-right`, `bottom-left` or `bottom-right`.
73
+
74
+ ## Data layers
75
+
76
+ **Markers.** `id` (required, unique) and `lngLat` are required. Optional: `tone`, `label` (always visible), `tooltip` (on hover), `popup` (on click: `title`, `description`, `meta: [{ label, value }]`), `icon` (Lucide kebab-case name, drawn in a 28 px pin), `size` (`sm` / `md` / `lg` dots of 10 / 14 / 20 px, used when there is no icon), `pulse` (animated ring), `draggable`.
77
+
78
+ **Routes.** `id` and `coordinates: [lng, lat][]` are required. Optional: `tone`, `width` (px), `opacity`, `dashed`, `progress` (0..1: draws a progress overlay and a marker there), `progressIcon` (default `navigation`), `progressMarker` (default `true`; set `false` when you track the vehicle with your own marker), `progressTone` (colour of the travelled part, default `primary`), `active` (emphasise the selected route), `showEndpoints` (start ring and end dot). Change one route with `updateRoute` (see [Live data](#live-data--in-place-updates)).
79
+
80
+ **Arcs.** Grouped. A group has `id`, `tone`, `width`, `curvature` and `data: { id, from, to, label? }[]`. Arc ids are unique within their group.
81
+
82
+ ```typescript
83
+ ui.map({
84
+ projection: 'globe',
85
+ arcs: [{ id: 'lanes', tone: 'chart-1', data: [{ id: 'lis-mad', from: [-9.14, 38.72], to: [-3.7, 40.42], label: 'Lisbon to Madrid' }] }],
86
+ onArcClick: (arc, group) => ui.notify(`${arc.label ?? arc.id} (${group.id})`),
87
+ });
88
+ ```
89
+
90
+ **GeoJSON.** `{ id, data, fillTone?, lineTone?, fillOpacity?, choropleth?, interactive?, promoteId? }`. `data` is a FeatureCollection, a Feature, or a URL string. A choropleth is `{ property, stops: [value, tone][] }`, with at least two stops. Without a choropleth the layer uses `fillTone`. The default fill opacity is `0.6` for a choropleth and `0.25` otherwise. The server does not validate GeoJSON.
91
+
92
+ **Clusters.** Grouped points: `{ id, points: [{ id, lngLat, properties? }], tone?, clusterRadius?, clusterMaxZoom? }`. Cluster bubbles use the group tone, `chart-2` and `chart-3`.
93
+
94
+ **Heatmaps.** `{ id, points: [{ lngLat, weight? }], ramp?, radius?, intensity?, opacity?, maxWeight?, showPointsAtZoom? }`. Weights default to `1` and must be finite and at least `0`. `maxWeight` is the weight that reaches full intensity; it defaults to the largest weight. The `ramp` runs from low to high density and defaults to `['chart-3', 'chart-4', 'primary', 'destructive']`, so it follows the palette and light or dark mode. `radius` (default `20` px at zoom 9) scales with zoom. Set `showPointsAtZoom` to fade the heatmap into individual circles when zoomed in. Replace the data with `setHeatmaps(layers)`; the client updates the source in place.
95
+
96
+ ```typescript
97
+ const heat = ui.map({ projection: 'globe', heatmaps: [{ id: 'quakes', points, maxWeight: 7, showPointsAtZoom: 6 }] });
98
+ heat.setHeatmaps([{ id: 'quakes', points: points.filter((p) => (p.weight ?? 0) >= 5) }]);
99
+ ```
100
+
101
+ **Standalone popup.** `showPopup([lng, lat], { title?, description? })` opens one, and `closePopup()` closes it. The `popup` prop does the same declaratively. Only marker popups take `meta` rows.
102
+
103
+ ## Camera
104
+
105
+ | Method | Behaviour |
106
+ |--------|-----------|
107
+ | `flyTo(center, zoom?, { duration? })` | Animates. Keeps the current zoom when `zoom` is omitted |
108
+ | `fitBounds([sw, ne], { padding? })` | Fits the given bounds |
109
+ | `fitBounds('markers', { padding? })` | Fits all current markers. Does nothing when there are none |
110
+ | `jumpTo(center, zoom?)` | Moves without animation |
111
+ | `setProjection('mercator' \| 'globe')` | Sends a new `projection` |
112
+
113
+ Each camera call runs once on the client. The server attaches a nonce, so repeating a call moves the map again.
114
+
115
+ The client reads `center`, `zoom`, `pitch`, `bearing` and `interactive` once, at mount. Changing those props later does not move an existing map. Use the camera methods instead.
116
+
117
+ ## Events
118
+
119
+ Handlers are registered only when you pass them. Each handler runs on the server, and receives:
120
+
121
+ | Handler | Fires when | Argument |
122
+ |---------|------------|----------|
123
+ | `onMarkerClick(marker)` | A marker is clicked | The current marker datum |
124
+ | `onMarkerDragEnd(marker, lngLat)` | A draggable marker is dropped | The updated marker and its new `lngLat`. The stored position is updated first |
125
+ | `onRouteClick(route)` | A route is clicked | The current route datum |
126
+ | `onArcClick(arc, group)` | An arc is clicked | The arc and its group |
127
+ | `onFeatureClick(feature)` | A GeoJSON feature is clicked | `{ layerId, featureId?, properties }` |
128
+ | `onClusterPointClick(point, group)` | A clustered point is clicked | The point and its group |
129
+ | `onMapClick(lngLat)` | The map is clicked | `[lng, lat]` |
130
+ | `onViewportChange(viewport)` | The camera stops moving (`moveend`) | `{ center, zoom, bearing, pitch }` |
131
+ | `onLocate(lngLat)` | The locate control is used (needs `controls.locate`) | `[lng, lat]` |
132
+
133
+
134
+ `markerDragEnd` is always recorded on the server, even without `onMarkerDragEnd`, so a later `fitBounds('markers')` uses the dropped position.
135
+
136
+ ## Live data & in-place updates
137
+
138
+ The map owns its marker, route, arc, cluster and popup state. Each setter sends only the prop it changes, so the client patches the map instead of rebuilding it. Markers are keyed by `id`: when a marker's `lngLat` changes, it moves, and its DOM node stays.
139
+
140
+ | Method | What it does |
141
+ |--------|--------------|
142
+ | `setMarkers(list)` | Replaces all markers |
143
+ | `upsertMarker(marker)` | Inserts, or replaces the marker with the same id |
144
+ | `removeMarker(id)` | Removes a marker. No-op if absent |
145
+ | `setRoutes(list)` / `updateRoute(id, patch)` | Replaces all routes, or merges `patch` into one. The id cannot change |
146
+ | `setArcs(groups)` / `setGeoJSON(layers)` / `setClusters(groups)` / `setHeatmaps(layers)` | Replace that layer type |
147
+
148
+ A courier moving along a route:
149
+
150
+ ```typescript
151
+ ui.timer(0.25, () => {
152
+ progress = Math.min(1, progress + 0.006);
153
+ map.updateRoute('delivery-route', { progress });
154
+ map.upsertMarker({ id: 'courier', lngLat: pointAt(progress), tone: 'chart-2', icon: 'truck' });
155
+ });
156
+ ```
157
+
158
+ **Validation** runs when you create the map or call a setter, before anything is sent. It throws on:
159
+
160
+ - a missing or empty id, or a duplicate id within its list (markers, routes, arc groups, arcs in a group, cluster groups, points in a group). The message is `duplicate id "…"`.
161
+ - a coordinate that is not `[lng, lat]` with two finite numbers. This covers markers, cluster points, route coordinates, arc endpoints, popups, `flyTo` and `jumpTo` centres, and `fitBounds` corners.
162
+
163
+ ## Layout
164
+
165
+ `height` gives a fixed-size map in normal flow:
166
+
167
+ ```typescript
168
+ ui.card({ title: 'Stores', className: 'p-0 overflow-hidden' }, () => {
169
+ ui.map({ height: '26rem', center: [-6, 40], zoom: 5 });
170
+ });
171
+ ```
172
+
173
+ `fill: true` makes the map fill its parent (`h-full min-h-0 flex-1`), so the parent needs a definite height. On a page that fills the window, put `fillViewport` on the outer column and `grow` on the panel that holds the map:
174
+
175
+ ```typescript
176
+ ui.column({ fillViewport: true, gap: 4 }, () => {
177
+ ui.row({ grow: true, wrap: false, gap: 4 }, () => {
178
+ ui.card({ title: 'Stores', scroll: true, className: 'w-80 shrink-0' }, () => { /* list */ });
179
+ ui.column({ grow: true }, () => {
180
+ ui.map({ fill: true, center: [-6, 40], zoom: 5 });
181
+ });
182
+ });
183
+ });
184
+ ```
185
+
186
+ See [API reference: Filling the page](./api.md#filling-the-page).
187
+
188
+ ## Tiles
189
+
190
+ By default the basemap uses CARTO's hosted styles: Positron for light and Dark Matter for dark, served from `basemaps.cartocdn.com`. Those styles are built on OpenStreetMap data. CARTO's free tier and terms apply, and OpenStreetMap data requires attribution.
191
+
192
+ Before production, check CARTO's current terms and attribution rules, or pass your own styles. Any MapLibre style URL works:
193
+
194
+ ```typescript
195
+ ui.map({ styles: { light: 'https://your-host.example/light.json', dark: 'https://your-host.example/dark.json' } });
196
+ ```
197
+
198
+ [OpenFreeMap](https://openfreemap.org) publishes free style URLs. Check its terms before you rely on it.
199
+
200
+ ## Demos
201
+
202
+ All five are under the **Maps** group in the sidebar.
203
+
204
+ - [`/examples/maps`](../apps/demo/src/examples/MapsGallery.ts): markers and popups, routes, arcs, a choropleth, clusters, a seismic heatmap on a globe, and a compact analytics map.
205
+ - [`/examples/delivery-tracker`](../apps/demo/src/examples/DeliveryTracker.ts): a courier moving along a route, with `updateRoute` and `upsertMarker` on a timer.
206
+ - [`/examples/store-locator`](../apps/demo/src/examples/StoreLocator.ts): a filterable store list where list rows and markers select each other.
207
+ - [`/examples/globe`](../apps/demo/src/examples/CommunityGlobe.ts): a globe with arcs and markers that refresh every 30 seconds.
208
+ - [`/examples/uptime`](../apps/demo/src/examples/UptimeMonitor.ts): a status globe of edge locations, refreshed every 2 seconds.
209
+
210
+ ## See also
211
+
212
+ - [Updating in place](./updating-in-place.md): the general rules for patching instead of rebuilding
213
+ - [API reference](./api.md#uimapprops): `ui.map` in the Layout section
214
+ - [Examples](./examples.md): the demo catalog
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@close-by/clay",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
4
4
  "description": "NiceGUI-like ui.* facade for Clay apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,10 +37,10 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@close-by/clay-compiler": "0.2.18",
41
- "@close-by/clay-core": "0.2.18",
42
- "@close-by/clay-components": "0.2.18",
43
- "@close-by/clay-server": "0.2.18",
40
+ "@close-by/clay-compiler": "0.2.19",
41
+ "@close-by/clay-core": "0.2.19",
42
+ "@close-by/clay-components": "0.2.19",
43
+ "@close-by/clay-server": "0.2.19",
44
44
  "@tailwindcss/cli": "^4.3.3",
45
45
  "tailwindcss": "^4.3.3"
46
46
  }
package/src/index.ts CHANGED
@@ -113,6 +113,9 @@ import {
113
113
  list as listFactory,
114
114
  imageCrop as imageCropFactory,
115
115
  gantt as ganttFactory,
116
+ map as mapFactory,
117
+ type MapElement,
118
+ type MapProps,
116
119
  chart as chartNamespace,
117
120
  table as tableFactory,
118
121
  flow as flowFactory,
@@ -346,6 +349,14 @@ import {
346
349
  type GanttDependency,
347
350
  type GanttRange,
348
351
  type GanttItemMovePayload,
352
+ type MapMarkerDatum,
353
+ type MapRouteDatum,
354
+ type MapArcGroup,
355
+ type MapGeoJSONLayer,
356
+ type MapClusterGroup,
357
+ type MapHeatmapLayer,
358
+ type LngLat,
359
+ type MapTone,
349
360
  type FlowProps,
350
361
  type FlowNodeProps,
351
362
  type FlowNodeKind,
@@ -455,6 +466,7 @@ import {
455
466
  timer as timerCore,
456
467
  storage as storageCore,
457
468
  theme as themeCore,
469
+ sound as soundCore,
458
470
  reactive as reactiveCore,
459
471
  state as stateCore,
460
472
  subscribe as subscribeCore,
@@ -642,6 +654,15 @@ export type {
642
654
  GanttDependency,
643
655
  GanttRange,
644
656
  GanttItemMovePayload,
657
+ MapProps,
658
+ MapMarkerDatum,
659
+ MapRouteDatum,
660
+ MapArcGroup,
661
+ MapGeoJSONLayer,
662
+ MapClusterGroup,
663
+ MapHeatmapLayer,
664
+ LngLat,
665
+ MapTone,
645
666
  FlowProps,
646
667
  FlowNodeProps,
647
668
  FlowNodeKind,
@@ -1180,6 +1201,15 @@ export function gantt(props?: GanttProps): GanttElement {
1180
1201
  return ganttFactory(props);
1181
1202
  }
1182
1203
 
1204
+ /**
1205
+ * Live map (MapLibre, styled to the Clay theme) with markers, routes, arcs,
1206
+ * GeoJSON layers, clusters and a popup. Owns its marker / route / popup /
1207
+ * camera state; setters update in place. See {@link MapProps}.
1208
+ */
1209
+ export function map(props?: MapProps): MapElement {
1210
+ return mapFactory(props);
1211
+ }
1212
+
1183
1213
  /**
1184
1214
  * Interactive flow diagram (`@xyflow/react`). Owns edges + positions;
1185
1215
  * node bodies are Clay trees; drag by card chrome (controls are nodrag);
@@ -1677,6 +1707,9 @@ export const storage = storageCore;
1677
1707
  /** Server-driven appearance (`light` | `dark` | `system`). */
1678
1708
  export const theme = themeCore;
1679
1709
 
1710
+ /** Interface sound cues: `ui.sound.set(on)`, `ui.sound.volume(0..1)`, `ui.sound.play(name)`. */
1711
+ export const sound = soundCore;
1712
+
1680
1713
  /** Proxy that notifies subscribers on property change. Prefer this over importing from `@close-by/clay-core`. */
1681
1714
  export const reactive = reactiveCore;
1682
1715
 
@@ -1924,6 +1957,7 @@ export const ui = {
1924
1957
  list,
1925
1958
  imageCrop,
1926
1959
  gantt,
1960
+ map,
1927
1961
  flow,
1928
1962
  stat,
1929
1963
  dataTable,
@@ -1991,6 +2025,7 @@ export const ui = {
1991
2025
  timer,
1992
2026
  storage,
1993
2027
  theme,
2028
+ sound,
1994
2029
  reactive,
1995
2030
  state,
1996
2031
  urlState,