slint-mapping 0.1.0

Map framework for Slint — interactive map component with tile rendering, panning, zooming, and marker overlays.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
// MapView / MapEmbed — slippy-map style map widget.
// =====================================================================
//
// Two entry points share the same internals:
//
//   - `MapView`  — a Window-rooted component. Use when the map IS the
//     whole window. Easiest to drive from Rust (`MapController::new`
//     takes a `&MapView`).
//
//   - `MapEmbed` — a Rectangle-rooted component. Use when the map is
//     embedded inside a larger Slint scene (e.g. with a floating
//     search card or a bottom sheet over the top). Has identical
//     properties + callbacks to `MapView` so the same Rust controller
//     drives it — see `examples/map_page.rs`.
//
// Visible tiles are an `in property <[Tile]>` model fed by the Rust
// side. Each `Tile` is positioned absolutely in the map's coordinate
// space; the Rust side computes which tiles overlap the viewport at
// the current centre/zoom and pushes them into the model. Slint paints
// each one as a regular `Image`, so the renderer handles compositing
// and alpha blending for free.

// One on-screen tile. `x` / `y` are pixel offsets relative to the
// top-left of the map; `size` is the tile's edge length (typically
// 256 px, but a source might use 512 for retina or vector rasterised).
export struct Tile {
    x: length,
    y: length,
    size: length,
    image: image,
}

// One marker overlay, projected into viewport pixels by the controller
// before being handed to MapEmbed. Kept geometry-only so the renderer
// can paint it without re-doing Web Mercator math in slint.
//
// Rendering:
// - With `icon` set (any image whose `width > 0`): tinted SVG/PNG at
//   `size`×`size`, centred on (x, y). `colour` is applied as the
//   `colorize` so monochrome glyphs (Material / Phosphor / Lucide
//   sets) recolour to whatever the layer wants. Pass an already-tinted
//   image and any colour to suppress the recolour.
// - Without `icon` (default empty image): filled circle of diameter
//   `size` with a thin white ring + soft shadow. This is the
//   fallback for "I just want a pin, no icon".
//
// Geographic positions ([lon, lat]) belong to the controller — see
// `slint_mapping::viewport::lonlat_to_viewport_px` for projecting them
// into the (x, y) this struct holds.
export struct Marker {
    x: length,
    y: length,
    size: length,
    colour: color,
    icon: image,
}

// One polyline overlay (route, GPS trace, area outline). `commands`
// is an SVG path string in viewport-pixel units, built Rust-side from
// a list of (lon, lat) points projected via
// `slint_mapping::viewport::lonlat_to_viewport_px`. Format: "M x0 y0
// L x1 y1 L x2 y2 …". Keeping path-building on the Rust side means
// every repaint at a new camera position emits fresh commands without
// any per-point reactive recomputation in slint.
//
// Visual: stroked, no fill. `colour` is the stroke colour, `width` is
// the stroke width. Rendered above tiles and below markers (inside a
// Layer, markers paint last so they sit on top of route lines).
export struct Polyline {
    commands: string,
    colour: color,
    width: length,
}

// A collection of overlay shapes that get painted on top of the tile
// layer as a group. Modeled on the GeoJSON / Mapbox-style "layer"
// concept: a single visual category (e.g. "saved places", "route") of
// mixed shape types that the consumer can show/hide as a unit.
// Polygons will join `polylines` + `markers` as a third field without
// breaking existing layer-building code (slint struct fields default
// to empty arrays when not set).
//
// Within a layer, paint order is: polylines first, markers last —
// markers always sit on top of route lines so pins don't get hidden
// under a thick route stroke.
//
// Across layers, paint order = array order: later entries draw on
// top of earlier ones, so put background context layers first and
// highlight layers last.
export struct Layer {
    markers: [Marker],
    polylines: [Polyline],
}

// Rectangle-rooted core. Encapsulates the tile layer + gesture handling.
// Embed this inside a parent Window when you need other chrome on top.
export component MapEmbed inherits Rectangle {
    in-out property <float> latitude: 0.0;
    in-out property <float> longitude: 0.0;
    in-out property <float> zoom: 2.0;
    in property <[Tile]> tiles;
    // Overlay layers, painted on top of the tile layer in array order.
    // Geometry inside each layer (markers etc.) is already projected to
    // viewport pixels by the controller — see
    // `slint_mapping::viewport::lonlat_to_viewport_px` for the lon/lat
    // → (x, y) conversion that should feed it.
    in property <[Layer]> layers;
    // Wheel-zoom over the map. Default on so desktop users get the
    // standard map-app gesture. Set false to let the wheel fall through
    // to an enclosing ScrollView (e.g. if you embed the map inside a
    // longer scrollable page).
    in property <bool> scroll-zoom: true;
    callback pan(length, length);              // (dx, dy) in pixels
    callback zoom-by(float, length, length);   // (delta, anchor-x, anchor-y)

    // Incremental-pan bookkeeping: each `moved` event must emit only
    // the delta since the *previous* move, not the cumulative drag
    // from press-start. Storing the last-seen pointer position so the
    // delta-emit-then-update step is correct.
    private property <length> last-x;
    private property <length> last-y;

    background: #1a1a1a;
    clip: true;

    // Tile layer. Each visible cell is always painted — when its
    // image isn't ready yet, we render a slightly lighter "loading"
    // square (with a faint grid line) so the map area reads as
    // "tiles arriving" rather than "missing data / off the world".
    // The empty-image check uses `tile.image.width`, which a default
    // `image` reports as 0px.
    for tile in root.tiles: Rectangle {
        x: tile.x;
        y: tile.y;
        // Each tile is oversized by 1 px on width AND height so the
        // right + bottom edges overlap the next tile's left + top
        // edges. Without this, fractional `tile.x` / `tile.y` from
        // zoomed/panned projection math leaves sub-pixel seams that
        // the renderer anti-aliases into a visible dark grid against
        // the MapEmbed's #1a1a1a background. The overlap costs a tiny
        // amount of paint redundancy on each frame; the visual win is
        // a seamless tile sheet at every zoom level.
        width: tile.size + 1px;
        height: tile.size + 1px;
        clip: true;
        // Placeholder fill for tiles whose image is still in flight.
        // Border deliberately omitted — the previous 1-px outline on
        // loading tiles was the other source of "grid line" visual
        // noise during initial pan / zoom bursts.
        background: tile.image.width == 0 ? #262626 : transparent;
        if (tile.image.width > 0): Image {
            width: parent.width;
            height: parent.height;
            source: tile.image;
            image-fit: ImageFit.fill;
        }
        // Animated dot pulse so the placeholder doesn't look like a
        // crashed tile. Single dot, centred, fading in and out.
        if (tile.image.width == 0): Rectangle {
            x: (parent.width - self.width) / 2;
            y: (parent.height - self.height) / 2;
            width: 6px;
            height: 6px;
            border-radius: 3px;
            background: #6a6a6a;
            opacity: 0.35 + 0.45 * abs(sin(animation-tick() / 1ms * 0.003 * 1rad));
        }
    }

    // Overlay layers. Drawn above the tiles but below the TouchArea so
    // pointer events pass through to the underlying map gestures. The
    // outer `for` wraps each layer in a transparent full-bleed surface
    // (slint's `for` body needs a single root element); inner `for`s
    // emit the actual shapes. Add `for polygon`, `for line` here when
    // those Layer fields land.
    for layer in root.layers: Rectangle {
        width: 100%;
        height: 100%;
        // Polylines first so markers paint on top — a route line
        // crossing a destination pin shouldn't hide the pin. The
        // viewbox locks the path's coordinate system to viewport
        // pixels (commands are built Rust-side in those units) so
        // there's no auto-scaling distortion when the path bounds
        // don't match the parent's bounds.
        for line in layer.polylines: Path {
            x: 0;
            y: 0;
            width: parent.width;
            height: parent.height;
            viewbox-x: 0;
            viewbox-y: 0;
            viewbox-width: parent.width / 1px;
            viewbox-height: parent.height / 1px;
            commands: line.commands;
            stroke: line.colour;
            stroke-width: line.width;
            fill: transparent;
        }
        // Marker shapes: either a tinted icon (when `icon` is set) or
        // a filled circle with a thin white ring + soft shadow as the
        // no-icon fallback. The check uses `icon.width`, which a
        // default `image` reports as 0px.
        for marker in layer.markers: Rectangle {
            x: marker.x - self.width / 2;
            y: marker.y - self.height / 2;
            width: marker.size;
            height: marker.size;
            // Icon variant — tinted SVG/PNG. The drop-shadow is on
            // the wrapper Rectangle so it tracks the icon's bounding
            // box, not the (possibly transparent) inner pixels.
            drop-shadow-blur: 4px;
            drop-shadow-color: #00000080;
            drop-shadow-offset-y: 1px;
            if (marker.icon.width > 0): Image {
                width: parent.width;
                height: parent.height;
                source: marker.icon;
                colorize: marker.colour;
                image-fit: ImageFit.contain;
            }
            // Circle fallback — runs when no icon was supplied.
            if (marker.icon.width == 0): Rectangle {
                width: parent.width;
                height: parent.height;
                border-radius: self.width / 2;
                background: marker.colour;
                border-width: 2px;
                border-color: white;
            }
        }
    }

    // Native pinch handler. Slint 1.16+'s ScaleRotateGestureHandler
    // fires when the platform sends a PinchGesture event up the input
    // tree. Coverage today: macOS + iOS (winit emits it from trackpad
    // / multitouch); silently dormant on Android and web until
    // winit-android and winit-web grow the same support. The
    // `updated` callback feeds into the same `zoom-by` plumbing the
    // wheel handler uses, so a Mac trackpad pinch zooms identically
    // to a mouse-wheel scroll without any extra controller wiring.
    //
    // Mapping: `scale` is the cumulative ratio since gesture start
    // (1.0 == no change, 2.0 == doubled). `log2(scale)` turns that
    // into a zoom-level delta because each integer zoom step doubles
    // the tile resolution. We track the previous scale so we emit
    // incremental deltas rather than cumulative ones — the
    // `zoom-by` callback expects deltas.
    private property <float> last-pinch-scale: 1.0;
    pinch := ScaleRotateGestureHandler {
        started => {
            root.last-pinch-scale = 1.0;
        }
        updated => {
            if (root.scroll-zoom && self.scale > 0) {
                root.zoom-by(
                    log(self.scale / root.last-pinch-scale, 2),
                    self.center.x,
                    self.center.y,
                );
                root.last-pinch-scale = self.scale;
            }
        }
    }

    // Pan / scroll-zoom surface. Pinch comes in via ScaleRotateGestureHandler
    // (above) on platforms that support it natively, and via synthetic
    // wheel events from JS on wasm — see wasm-demo/web/index.html.
    touch := TouchArea {
        pointer-event(event) => {
            // Reset the incremental anchor on press-down so the first
            // `moved` after a press doesn't see a stale delta from a
            // previous drag.
            if (event.kind == PointerEventKind.down) {
                root.last-x = self.mouse-x;
                root.last-y = self.mouse-y;
            }
        }
        moved => {
            if (self.pressed) {
                root.pan(self.mouse-x - root.last-x, self.mouse-y - root.last-y);
                root.last-x = self.mouse-x;
                root.last-y = self.mouse-y;
            }
        }
        scroll-event(event) => {
            if (root.scroll-zoom) {
                // event.delta-y is positive on scroll up. Anchor on the
                // cursor so the point under the mouse stays under it.
                root.zoom-by(event.delta-y / 120px, self.mouse-x, self.mouse-y);
                accept
            } else {
                reject
            }
        }
    }
}

// Window-rooted demo that shows MapEmbed inside a richer layout — a
// floating search card pinned at the top and an "info" card at the
// bottom, both over a full-bleed map. Modeled on the MapPage from
// slint-mobile-components/crates/pages-travel/ui/map.slint. Surfaces
// the same `pan`/`zoom-by` callbacks as MapView so MapController
// drives it identically.
export component MapPageDemo inherits Window {
    preferred-width: 412px;
    preferred-height: 892px;
    background: #1a1a1a;
    title: "slint-mapping — MapPage demo";

    in-out property <float> latitude: 0.0;
    in-out property <float> longitude: 0.0;
    in-out property <float> zoom: 2.0;
    in property <[Tile]> tiles;
    in property <[Layer]> layers;
    in-out property <string> query;
    callback pan(length, length);
    callback zoom-by(float, length, length);

    // ----- Map (fills the window) -----
    MapEmbed {
        x: 0;
        y: 0;
        width: parent.width;
        height: parent.height;
        latitude <=> root.latitude;
        longitude <=> root.longitude;
        zoom <=> root.zoom;
        tiles: root.tiles;
        layers: root.layers;
        pan(dx, dy) => { root.pan(dx, dy); }
        zoom-by(d, ax, ay) => { root.zoom-by(d, ax, ay); }
    }

    // ----- Floating search card (top) -----
    Rectangle {
        x: 16px;
        y: 48px;
        width: parent.width - 32px;
        height: 52px;
        background: #ffffffee;
        border-radius: 12px;
        drop-shadow-blur: 18px;
        drop-shadow-color: #00000066;
        drop-shadow-offset-y: 6px;
        HorizontalLayout {
            padding-left: 16px;
            padding-right: 16px;
            spacing: 8px;
            alignment: stretch;
            VerticalLayout {
                alignment: center;
                horizontal-stretch: 1;
                Text {
                    text: root.query == "" ? "Search the map" : root.query;
                    color: root.query == "" ? #888888 : #111111;
                    font-size: 15px;
                    font-weight: 600;
                    vertical-alignment: center;
                }
            }
        }
    }

    // ----- Info card (bottom) — shows live camera state -----
    Rectangle {
        x: 16px;
        y: parent.height - self.height - 24px;
        width: parent.width - 32px;
        height: 80px;
        background: #1a1d24ee;
        border-radius: 12px;
        drop-shadow-blur: 18px;
        drop-shadow-color: #00000066;
        drop-shadow-offset-y: 6px;
        VerticalLayout {
            padding: 12px;
            spacing: 4px;
            Text {
                text: "Camera";
                color: #ffffff99;
                font-size: 10px;
                font-weight: 800;
                letter-spacing: 1px;
            }
            Text {
                text: "lat " + round(root.latitude * 10000) / 10000
                    + "   lon " + round(root.longitude * 10000) / 10000
                    + "   z " + round(root.zoom * 10) / 10;
                color: #ffffff;
                font-size: 14px;
                font-weight: 700;
                font-family: "Consolas";
            }
        }
    }
}

// Window-rooted convenience wrapper. Same properties + callbacks as
// MapEmbed, forwarded into it.
export component MapView inherits Window {
    preferred-width: 800px;
    preferred-height: 600px;
    background: #1a1a1a;
    title: "slint-mapping";

    in-out property <float> latitude: 0.0;
    in-out property <float> longitude: 0.0;
    in-out property <float> zoom: 2.0;
    in property <[Tile]> tiles;
    callback pan(length, length);
    callback zoom-by(float, length, length);

    embed := MapEmbed {
        width: 100%;
        height: 100%;
        latitude <=> root.latitude;
        longitude <=> root.longitude;
        zoom <=> root.zoom;
        tiles: root.tiles;
        pan(dx, dy) => { root.pan(dx, dy); }
        zoom-by(d, ax, ay) => { root.zoom-by(d, ax, ay); }
    }
}

// Window-rooted "subwindow" wrapper that hosts MapEmbed inside surrounding
// chrome — a titlebar at the top and a uniform border. The inner MapEmbed
// is *not* at (0, 0) of the window: it's offset by `(border, titlebar_h +
// border)` and sized smaller than the window. This is the use case where
// gesture maths would break if either:
//   - cursor coords were assumed to be window-local (they're TouchArea-local,
//     i.e. MapEmbed-local — independent of MapEmbed's parent offset), or
//   - the viewport size were assumed to be the window size (it's the
//     MapEmbed's measured size — surfaced via `map-viewport-width` /
//     `map-viewport-height` for the controller to read).
//
// Test target for `tests/subwindow.rs` — those tests verify the offset
// MapEmbed reports the right viewport size and that anchor maths still
// pins the cursor pixel after a zoom.
export component MapPanel inherits Window {
    preferred-width: 480px;
    preferred-height: 360px;
    background: #2a2d34;
    title: "slint-mapping — map panel";

    in-out property <float> latitude: 0.0;
    in-out property <float> longitude: 0.0;
    in-out property <float> zoom: 2.0;
    in property <[Tile]> tiles;
    in property <[Layer]> layers;
    in property <string> panel-title: "Map";
    callback pan(length, length);
    callback zoom-by(float, length, length);
    // MapEmbed's measured size — what a controller must use as the
    // viewport for projection / anchor math, since it's NOT the
    // window's size.
    out property <length> map-viewport-width: embed.width;
    out property <length> map-viewport-height: embed.height;

    // Titlebar strip across the top.
    Rectangle {
        x: 0;
        y: 0;
        width: parent.width;
        height: 32px;
        background: #1a1d24;
        Text {
            x: 12px;
            y: (parent.height - self.height) / 2;
            text: root.panel-title;
            color: #ffffffcc;
            font-size: 13px;
            font-weight: 700;
        }
    }

    // The inner map — offset from the window origin by the titlebar
    // height + a uniform 12 px border on the other three sides.
    embed := MapEmbed {
        x: 12px;
        y: 32px + 12px;
        width: parent.width - 24px;
        height: parent.height - 32px - 24px;
        latitude <=> root.latitude;
        longitude <=> root.longitude;
        zoom <=> root.zoom;
        tiles: root.tiles;
        layers: root.layers;
        pan(dx, dy) => { root.pan(dx, dy); }
        zoom-by(d, ax, ay) => { root.zoom-by(d, ax, ay); }
    }
}