@close-by/clay 0.2.18 → 0.2.20

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 |
@@ -1219,6 +1220,21 @@ ui.radialChart({
1219
1220
  });
1220
1221
  ```
1221
1222
 
1223
+ #### Live chart updates
1224
+
1225
+ Every chart factory and `ui.chart.*` builder returns a `ChartElement`:
1226
+
1227
+ | Method | What it sends |
1228
+ |--------|---------------|
1229
+ | `appendData(rows, { max? })` | `appendRows` with only the new rows; client trims to the last `max` |
1230
+ | `setData(rows)` | `updateProps` (`data`) |
1231
+ | `upsertData(row, { key?, max? })` | Replaces the row with the same `key` (default `xKey`, or `nameKey` for pies) or appends it |
1232
+ | `setSeries(series)` / `setHeadline(h)` / `setTitle(t)` / `setDescription(d)` / `setLoading(b)` | `updateProps` for that prop |
1233
+
1234
+ `ui.stat(items)` returns a `StatElement` with `setItems(items)`, `updateItem(titleOrIndex, patch)` and `appendSparkline(titleOrIndex, points, { max? })`. Each sends one `updateProps` (`items`).
1235
+
1236
+ All charts accept `animate?: boolean` (default `true`). See [Updating in place](./updating-in-place.md#live-charts-appenddata--setdata--upsertdata).
1237
+
1222
1238
  #### Structured charts (`ui.chart`)
1223
1239
 
1224
1240
  Optional sugar over the props APIs above. Mode-first builders compile to the same `Element` types — legacy `ui.areaChart` / `ui.pieChart` / etc. remain fully supported.
@@ -1804,6 +1820,10 @@ onClick: async () => {
1804
1820
  | `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
1821
  | `ui.theme.getPalette()` | `ThemePalette \| null` | Last palette set, else the one the client reported on connect |
1806
1822
  | `ui.theme.palettes` | `readonly string[]` | Built-in palette names |
1823
+ | `ui.sound.set(on)` | `void` | Turn automatic interface sounds on/off for this client; remembered across reloads. See [Sound](#sound) |
1824
+ | `ui.sound.volume(v)` | `void` | Master volume `0`–`1` (default `0.5`); remembered across reloads |
1825
+ | `ui.sound.play(name)` | `void` | Play one cue now, even when automatic sounds are off |
1826
+ | `ui.sound.names` | `readonly string[]` | Built-in cue names |
1807
1827
  | `ui.runJavaScript(code)` | `void` | Eval trusted snippet in the browser |
1808
1828
  | `ui.scroll.to(opts)` | `void` | Scroll window (`top` / `bottom` / px) |
1809
1829
  | `ui.scroll.intoView(selector, opts?)` | `void` | `Element.scrollIntoView` via selector |
@@ -1918,6 +1938,7 @@ All layout helpers accept either `(fn, props?)` or `(props, fn)`.
1918
1938
  | `ui.hero` | Centered hero region |
1919
1939
  | `ui.card` | Card; callback receives the card element |
1920
1940
  | `ui.app` | SPA shell: sidebar nav + centered main content |
1941
+ | `ui.map` | Live map (MapLibre): markers, routes, arcs, GeoJSON, clusters, popup. See [Maps](./maps.md) |
1921
1942
 
1922
1943
  #### `ui.app(props, fn)`
1923
1944
 
@@ -2016,6 +2037,15 @@ ui.container(() => {
2016
2037
  }, { centered: true, width: 'md' });
2017
2038
  ```
2018
2039
 
2040
+ #### `ui.map(props?)`
2041
+
2042
+ 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).
2043
+
2044
+ ```typescript
2045
+ const fleet = ui.map({ fill: true, markers: vehicles(), onMarkerClick: (m) => select(m.id) });
2046
+ fleet.fitBounds('markers', { padding: 48 });
2047
+ ```
2048
+
2019
2049
  ---
2020
2050
 
2021
2051
  ## `Element` methods (`@close-by/clay-core`)
@@ -2183,6 +2213,33 @@ import { button, input /* … */ } from '@close-by/clay-components';
2183
2213
 
2184
2214
  ---
2185
2215
 
2216
+ ## Sound
2217
+
2218
+ 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.
2219
+
2220
+ 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.
2221
+
2222
+ When enabled, a page-wide listener picks the cue from the element, so components need no wiring:
2223
+
2224
+ | Interaction | Cue |
2225
+ |---|---|
2226
+ | Button, link, `role="button"` | `tap` (`soft` for `ghost` / `link` variants, `destructive` for the destructive variant) |
2227
+ | Tab, menu item, option, radio | `select` (pitch rises with position) |
2228
+ | Switch, checkbox | `toggleOn` / `toggleOff` |
2229
+ | Disabled control (when it still receives the press) | `blocked` |
2230
+ | Dialog / sheet / alert dialog opens or closes | `open` / `close` |
2231
+ | Slider value changes | `tick` |
2232
+ | OTP digit typed | `key` |
2233
+ | `ui.notify` toast | `success` / `error` / `warning` / `notification` by type |
2234
+ | `ui.clipboard` / code block copy | `copy` |
2235
+
2236
+ 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`.
2237
+
2238
+ ```ts
2239
+ // A settings switch that lets each user turn sounds on or off
2240
+ ui.switch({ label: 'Interface sounds', onChange: (on) => ui.sound.set(on) });
2241
+ ```
2242
+
2186
2243
  ## DuckDB (`@close-by/clay-duckdb`)
2187
2244
 
2188
2245
  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
 
@@ -26,6 +26,7 @@ Each page sets `group` / `groupIcon` in its `pageMeta`, so the sidebar and the h
26
26
  | `/examples/reui-batches` | `ReuiBatchesDemo.ts` | ReUI batches 3–6: `buttonGroup`, `empty`, `pagination`, `filterBar`, form inputs, `navigationMenu`, `notice`, `stat`+sparkline, `eventCalendar`, datatable `showFilterChips` |
27
27
  | `/examples/reui-batch-7` | `ReuiBatch7Demo.ts` | ReUI batch 7: `inputGroup`, `toggle`, `descriptionList`, `staticTable`, `aspectRatio`, `itemList`, `checkboxGroup` |
28
28
  | `/examples/charts` | `ChartDemo.ts` | `ui.chart.*`, `ui.sparkline`, composed/scatter charts |
29
+ | `/examples/live-charts` | `LiveCharts.ts` | Live charts: `appendData` sliding windows, `setData` tweens, `upsertData` buckets, live gauge and sparklines |
29
30
  | `/examples/slider-demo` | `SliderDemo.ts` | Slider, checkbox, select + bindings |
30
31
  | `/examples/themes` | `ThemesDemo.ts` | Palette gallery: `ui.theme.palette`, light/dark, component preview |
31
32
  | `/examples/layout` | `LayoutDemo.ts` | Fill layout: `fillViewport`, `grow`, `scroll`, `ui.grid` |
@@ -44,6 +45,11 @@ Each page sets `group` / `groupIcon` in its `pageMeta`, so the sidebar and the h
44
45
  | `/examples/gantt` | `GanttDemo.ts` | `ui.gantt` owned rows/dates/markers/deps, cross-row drag, header double-click markers, `itemMove` / `markerAdd` settle |
45
46
  | `/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
47
  | `/examples/data-clients` | `DataClientsDemo.ts` | DuckDB / Kibana / ClickHouse integration story (mock-friendly) |
48
+ | `/examples/maps` | `MapsGallery.ts` | `ui.map` gallery: markers and popups, routes, arcs, choropleth, clusters, compact analytics map |
49
+ | `/examples/delivery-tracker` | `DeliveryTracker.ts` | `ui.map` route progress and courier marker: `updateRoute` / `upsertMarker` on a timer, `fill` layout |
50
+ | `/examples/store-locator` | `StoreLocator.ts` | `ui.map` filtered markers, `flyTo` / `fitBounds('markers')`, popups; list and map select each other by key |
51
+ | `/examples/globe` | `CommunityGlobe.ts` | `ui.map` globe projection, arcs, `setMarkers` / `setArcs` on a 30 s timer |
52
+ | `/examples/uptime` | `UptimeMonitor.ts` | `ui.map` status markers on a globe, refreshed every 2 s with `setMarkers` |
47
53
  | `/examples/kitchen-sink` | `KitchenSink.ts` | ShadCN catalog preview (client `KitchenSink`) |
48
54
 
49
55
  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/docs/protocol.md CHANGED
@@ -79,6 +79,7 @@ Browser Back/Forward (or other `popstate`) changed the query and/or hash **witho
79
79
  | `replace` | `id`, `node` | Replace entire subtree at `id` |
80
80
  | `updateProps` | `id`, `props` | Shallow-merge props |
81
81
  | `setChildren` | `id`, `children` | Replace children array |
82
+ | `appendRows` | `id`, `prop`, `rows`, `max?` | Append `rows` to the array prop `prop`, then keep the last `max` items (chart streaming) |
82
83
  | `remove` | `id` | Remove node from parent |
83
84
 
84
85
  ### `navigate`
@@ -20,6 +20,7 @@ The flicker is caused by the remount, not by the new value. If a frame carries o
20
20
  | Change one value (a label, badge, or input) | `label.setText(…)`, `el.setValue(…)`, `el.update({ … })` | `updateProps` for that element |
21
21
  | Live table data | `table.setRows(rows)` on a table created with a stable `keyField` (add `highlightChanges: true` to flash changed rows) | `updateProps` on the table; rows keyed by `keyField` keep their DOM |
22
22
  | Table refetch | `table.setLoading(true)` / `table.withLoading(fn)` | `updateProps` (`loading`); previous rows stay, dimmed, instead of blanking |
23
+ | Live chart data (any chart) | `chart.appendData(point, { max })` for a stream, `chart.setData(rows)` for a snapshot, `chart.upsertData(row)` for a growing bucket | `appendRows` with only the new points, or `updateProps` (`data`); the chart stays mounted |
23
24
  | A section whose structure depends on data | `ui.refreshable(() => …)` + `.refresh()`, or `ui.auto(() => …)` | Reconciled: matched children get `updateProps` only; added, removed, or reordered children cause one `setChildren` per affected parent |
24
25
  | Items in a list inside either of the above | `.key(item.id)` on each item | Items keep their identity through reorder, filter, and prepend |
25
26
 
@@ -31,7 +32,7 @@ When a `refreshable` or `auto` block re-runs, Clay builds a fresh tree and match
31
32
 
32
33
  1. **Keyed first.** A new child with `.key(k)` matches the old child with the same key and the same element type. Keyed children are matched only by key.
33
34
  2. **Unkeyed by position.** Unkeyed children are matched in order, by element type. If the next old child matches the current new one, the current old child was removed and is skipped. If the current old child matches the next new one, the new child was inserted. Otherwise the pair is treated as a replacement at that position. This lookahead covers a single insert or remove; larger reshuffles should be keyed.
34
- 3. **Matched pairs keep the old element and its id.** The fresh element's handlers and props are copied onto the old one. Only props that actually changed are sent, as one `updateProps`.
35
+ 3. **Matched pairs keep the id on screen.** The freshly built element takes over the old element's id and the old one is destroyed. Only props that actually changed are sent, as one `updateProps`. Because the fresh instance is the one that stays live, its built-in handlers (table search, sort and paging, map drags) and any reference you saved while building (`table = ui.dataTable(...)`) keep working after the refresh.
35
36
  4. **Leftovers.** Old children with no match are destroyed. New children with no match are mounted. The parent sends one `setChildren` only if its list of ids changed. A child whose type changed at a position is replaced; its siblings keep their ids.
36
37
  5. **Nested blocks keep their instance.** A nested `refreshable` or `auto` keeps its id and its subscriptions and adopts the new builder, so its own refreshes keep working.
37
38
 
@@ -103,11 +104,35 @@ async function refresh() {
103
104
 
104
105
  If you want columns to stay fixed across refreshes, pass `columns` explicitly. Otherwise columns are inferred again on each `setRows`.
105
106
 
107
+ ### Live charts (`appendData` / `setData` / `upsertData`)
108
+
109
+ Every chart (`ui.lineChart`, `areaChart`, `barChart`, `composedChart`, `scatterChart`, `pieChart`, `radarChart`, `radialChart`, `sparkline`, and the `ui.chart.*` builders) returns a `ChartElement`. Create it once and push data into it.
110
+
111
+ ```typescript
112
+ const latency = ui.lineChart({ data: seed, xKey: 't', series: [{ key: 'p50', label: 'p50' }] });
113
+ const byRegion = ui.barChart({ data: regions, xKey: 'region', series: [{ key: 'orders', label: 'Orders' }] });
114
+
115
+ ui.timer(1, () => {
116
+ latency.appendData({ t: clock(), p50: sample() }, { max: 60 }); // only the new point is sent
117
+ latency.setHeadline({ value: `${p50} ms` });
118
+ });
119
+ ui.timer(5, async () => byRegion.setData(await fetchRegions())); // bars tween to the new values
120
+ ```
121
+
122
+ - **`appendData(rows, { max })`** sends only the new rows. The client appends them and drops the oldest past `max`, so a sliding window costs one point per tick.
123
+ - **`setData(rows)`** replaces the data. When the row count and first x value are unchanged, the marks tween to their new values.
124
+ - **`upsertData(row, { key, max })`** replaces the row with the same x value (or `nameKey` for pies), or appends it. Use it for a bucket that keeps growing, such as the current minute.
125
+ - **`setSeries`, `setHeadline`, `setTitle`, `setDescription`, `setLoading`** patch one prop each. Anything else goes through `update({ … })`.
126
+
127
+ KPI stat grids work the same way: `ui.stat(items)` returns a `StatElement`. `updateItem(titleOrIndex, patch)` changes one card, `appendSparkline(titleOrIndex, points, { max })` extends that card's sparkline, and `setItems(items)` replaces them all.
128
+
129
+ Appends and sliding windows draw without animation, so lines don't slide sideways or replay their entrance. Pass `animate: false` to turn chart animation off entirely. The interactive period tabs keep the selected period while data streams in.
130
+
106
131
  ## Anti-patterns
107
132
 
108
133
  - **Rebuilding a whole section when one value changes.** Wrapping a whole page in `ui.refreshable` and calling `.refresh()` every second to update a clock. Keep a reference to the clock label and call `setText`.
109
134
  - **Unkeyed lists that get sorted or filtered.** Without `.key()`, position is the identity. After a sort, the element in slot 3 is reused for whatever item is now third, so focus and typed text stay in the slot instead of following the row.
110
- - **Creating a new table instead of `setRows`.** A `ui.dataTable(...)` created inside a refreshable on every tick remounts the table and loses sort, scroll, selection, and search. Create the table once and call `setRows`.
135
+ - **Creating a new table instead of `setRows`.** A `ui.dataTable(...)` rebuilt inside a refreshable keeps its id and stays interactive, but each rebuild starts the table's server-side state over, so sort, search, page and selection reset. Create the table once and call `setRows`.
111
136
  - **Tables without `keyField`.** Rows are stamped `__rowId` by their index, so inserting or re-sorting the source shifts every row's identity. Set `keyField` to your primary key.
112
137
  - **Toggling a constructor option by rebuilding.** Recreating a table with `zebra: on` inside an `auto` block. Use the setters instead: `setZebra`, `setDensity`, `setHighlightChanges`.
113
138
 
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.20",
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.20",
41
+ "@close-by/clay-core": "0.2.20",
42
+ "@close-by/clay-components": "0.2.20",
43
+ "@close-by/clay-server": "0.2.20",
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,
@@ -200,6 +203,8 @@ import {
200
203
  type CheckboxGroupProps,
201
204
  type CheckboxGroupOption,
202
205
  type StatItem,
206
+ type StatItemRef,
207
+ type StatElement,
203
208
  type DataTableProps,
204
209
  type DataTableAction,
205
210
  type DataTableView,
@@ -232,6 +237,7 @@ import {
232
237
  type ComposedChartProps,
233
238
  type ComposedChartSeries,
234
239
  type ComposedSeriesType,
240
+ type ChartElement,
235
241
  type CartesianChartProps,
236
242
  type ChartSeries,
237
243
  type ChartHeadline,
@@ -346,6 +352,14 @@ import {
346
352
  type GanttDependency,
347
353
  type GanttRange,
348
354
  type GanttItemMovePayload,
355
+ type MapMarkerDatum,
356
+ type MapRouteDatum,
357
+ type MapArcGroup,
358
+ type MapGeoJSONLayer,
359
+ type MapClusterGroup,
360
+ type MapHeatmapLayer,
361
+ type LngLat,
362
+ type MapTone,
349
363
  type FlowProps,
350
364
  type FlowNodeProps,
351
365
  type FlowNodeKind,
@@ -412,7 +426,6 @@ import {
412
426
  DialogElement,
413
427
  DialogStackElement,
414
428
  StepperElement,
415
- NavigationMenuElement,
416
429
  AlertDialogElement,
417
430
  DropdownMenuElement,
418
431
  ContextMenuElement,
@@ -455,6 +468,7 @@ import {
455
468
  timer as timerCore,
456
469
  storage as storageCore,
457
470
  theme as themeCore,
471
+ sound as soundCore,
458
472
  reactive as reactiveCore,
459
473
  state as stateCore,
460
474
  subscribe as subscribeCore,
@@ -515,6 +529,9 @@ export {
515
529
  } from './tailwind';
516
530
 
517
531
 
532
+ export { ChartElement, StatElement } from '@close-by/clay-components';
533
+ export type { StatItemRef } from '@close-by/clay-components';
534
+
518
535
  export type {
519
536
  GridProps,
520
537
  LayoutProps,
@@ -642,6 +659,15 @@ export type {
642
659
  GanttDependency,
643
660
  GanttRange,
644
661
  GanttItemMovePayload,
662
+ MapProps,
663
+ MapMarkerDatum,
664
+ MapRouteDatum,
665
+ MapArcGroup,
666
+ MapGeoJSONLayer,
667
+ MapClusterGroup,
668
+ MapHeatmapLayer,
669
+ LngLat,
670
+ MapTone,
645
671
  FlowProps,
646
672
  FlowNodeProps,
647
673
  FlowNodeKind,
@@ -1180,6 +1206,15 @@ export function gantt(props?: GanttProps): GanttElement {
1180
1206
  return ganttFactory(props);
1181
1207
  }
1182
1208
 
1209
+ /**
1210
+ * Live map (MapLibre, styled to the Clay theme) with markers, routes, arcs,
1211
+ * GeoJSON layers, clusters and a popup. Owns its marker / route / popup /
1212
+ * camera state; setters update in place. See {@link MapProps}.
1213
+ */
1214
+ export function map(props?: MapProps): MapElement {
1215
+ return mapFactory(props);
1216
+ }
1217
+
1183
1218
  /**
1184
1219
  * Interactive flow diagram (`@xyflow/react`). Owns edges + positions;
1185
1220
  * node bodies are Clay trees; drag by card chrome (controls are nodrag);
@@ -1193,7 +1228,7 @@ export function flow(a: any, b?: any): FlowElement {
1193
1228
  }
1194
1229
 
1195
1230
  /** Stat strip (label/value items). Prefer `ui.stat`. */
1196
- export function stat(items: StatItem[], props?: { className?: string }): Element {
1231
+ export function stat(items: StatItem[], props?: { className?: string }): StatElement {
1197
1232
  return statFactory(items, props);
1198
1233
  }
1199
1234
 
@@ -1217,47 +1252,47 @@ export const chart = chartNamespace;
1217
1252
  export const ai = aiNamespace;
1218
1253
 
1219
1254
  /** Area chart. Prefer `ui.areaChart`. See {@link AreaChartProps}. */
1220
- export function areaChart(props: AreaChartProps): Element {
1255
+ export function areaChart(props: AreaChartProps): ChartElement {
1221
1256
  return areaChartFactory(props);
1222
1257
  }
1223
1258
 
1224
1259
  /** Bar chart. Prefer `ui.barChart`. See {@link BarChartProps}. */
1225
- export function barChart(props: BarChartProps): Element {
1260
+ export function barChart(props: BarChartProps): ChartElement {
1226
1261
  return barChartFactory(props);
1227
1262
  }
1228
1263
 
1229
1264
  /** Line chart. Prefer `ui.lineChart`. See {@link LineChartProps}. */
1230
- export function lineChart(props: LineChartProps): Element {
1265
+ export function lineChart(props: LineChartProps): ChartElement {
1231
1266
  return lineChartFactory(props);
1232
1267
  }
1233
1268
 
1234
1269
  /** Pie / donut chart. Prefer `ui.pieChart`. See {@link PieChartProps}. */
1235
- export function pieChart(props: PieChartProps): Element {
1270
+ export function pieChart(props: PieChartProps): ChartElement {
1236
1271
  return pieChartFactory(props);
1237
1272
  }
1238
1273
 
1239
1274
  /** Radar chart. Prefer `ui.radarChart`. See {@link RadarChartProps}. */
1240
- export function radarChart(props: RadarChartProps): Element {
1275
+ export function radarChart(props: RadarChartProps): ChartElement {
1241
1276
  return radarChartFactory(props);
1242
1277
  }
1243
1278
 
1244
1279
  /** Radial / gauge chart. Prefer `ui.radialChart`. See {@link RadialChartProps}. */
1245
- export function radialChart(props: RadialChartProps): Element {
1280
+ export function radialChart(props: RadialChartProps): ChartElement {
1246
1281
  return radialChartFactory(props);
1247
1282
  }
1248
1283
 
1249
1284
  /** Scatter chart. Prefer `ui.scatterChart`. See {@link ScatterChartProps}. */
1250
- export function scatterChart(props: ScatterChartProps): Element {
1285
+ export function scatterChart(props: ScatterChartProps): ChartElement {
1251
1286
  return scatterChartFactory(props);
1252
1287
  }
1253
1288
 
1254
1289
  /** Composed multi-geometry chart. Prefer `ui.composedChart`. See {@link ComposedChartProps}. */
1255
- export function composedChart(props: ComposedChartProps): Element {
1290
+ export function composedChart(props: ComposedChartProps): ChartElement {
1256
1291
  return composedChartFactory(props);
1257
1292
  }
1258
1293
 
1259
1294
  /** KPI card with inline sparkline. Prefer `ui.sparkline`. See {@link SparklineProps}. */
1260
- export function sparkline(props: SparklineProps): Element {
1295
+ export function sparkline(props: SparklineProps): ChartElement {
1261
1296
  return sparklineFactory(props);
1262
1297
  }
1263
1298
 
@@ -1677,6 +1712,9 @@ export const storage = storageCore;
1677
1712
  /** Server-driven appearance (`light` | `dark` | `system`). */
1678
1713
  export const theme = themeCore;
1679
1714
 
1715
+ /** Interface sound cues: `ui.sound.set(on)`, `ui.sound.volume(0..1)`, `ui.sound.play(name)`. */
1716
+ export const sound = soundCore;
1717
+
1680
1718
  /** Proxy that notifies subscribers on property change. Prefer this over importing from `@close-by/clay-core`. */
1681
1719
  export const reactive = reactiveCore;
1682
1720
 
@@ -1924,6 +1962,7 @@ export const ui = {
1924
1962
  list,
1925
1963
  imageCrop,
1926
1964
  gantt,
1965
+ map,
1927
1966
  flow,
1928
1967
  stat,
1929
1968
  dataTable,
@@ -1991,6 +2030,7 @@ export const ui = {
1991
2030
  timer,
1992
2031
  storage,
1993
2032
  theme,
2033
+ sound,
1994
2034
  reactive,
1995
2035
  state,
1996
2036
  urlState,