// 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); }
}
}