diff --git a/package-lock.json b/package-lock.json
index 6177083b77e2..5e663ba0c277 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22397,6 +22397,19 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/mapbox-gl": {
+ "version": "3.28.1",
+ "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
+ "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
+ "dev": true,
+ "license": "SEE LICENSE IN LICENSE.txt",
+ "workspaces": [
+ "src/style-spec",
+ "plugins/mapbox-gl-pmtiles-provider",
+ "test/bundlers/*",
+ "test/build/typings"
+ ]
+ },
"node_modules/mark.js": {
"version": "8.11.1",
"resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz",
@@ -36252,6 +36265,7 @@
"jspdf": "^2.5.2",
"lucide-svelte": "^0.298.0",
"luxon": "^3.5.0",
+ "mapbox-gl": "^3.0.0",
"marked": "^16.4.0",
"match-sorter": "^6.3.1",
"memoizee": "^0.4.17",
diff --git a/runtime/canvas/component.go b/runtime/canvas/component.go
index 090a31f11dd7..7c38708be2fd 100644
--- a/runtime/canvas/component.go
+++ b/runtime/canvas/component.go
@@ -45,6 +45,8 @@ func ValidateRendererProperties(renderer string, props map[string]any, metricsVi
return validatePivot(props, metricsViews)
case "leaderboard":
return validateLeaderboard(props, metricsViews)
+ case "map":
+ return validateMap(props, metricsViews)
case "custom_chart":
// TODO: Implement
return nil
@@ -386,6 +388,37 @@ func validateLeaderboard(props map[string]any, metricsViews map[string]*runtimev
return nil
}
+// validateMap validates properties for map.
+func validateMap(props map[string]any, metricsViews map[string]*runtimev1.MetricsViewSpec) error {
+ mvn, mv, err := requireMetricsView(props, metricsViews)
+ if err != nil {
+ return err
+ }
+
+ geoDim, ok := pathutil.GetPathString(props, "geo_dimension")
+ if !ok {
+ return errors.New("renderer properties for map must include a string 'geo_dimension' property")
+ }
+ if !metricsViewHasDimension(mv, geoDim) {
+ return fmt.Errorf("referenced geo_dimension %q is not a dimension in metrics view %q", geoDim, mvn)
+ }
+
+ // Color can be a plain string (color literal) or a map with a "measure" key.
+ if raw, ok := pathutil.GetPath(props, "color"); ok {
+ if _, isString := raw.(string); !isString {
+ if err := validateOptionalMeasureField(mv, mvn, props, "color.measure"); err != nil {
+ return err
+ }
+ }
+ }
+
+ if err := validateOptionalMeasureField(mv, mvn, props, "size_measure"); err != nil {
+ return err
+ }
+
+ return validateOptionalDimensionField(mv, mvn, props, "tooltip_dimension")
+}
+
// requireMetricsView extracts and validates the "metrics_view" property from renderer props.
// It returns the metrics view name, spec, and nil error on success.
func requireMetricsView(props map[string]any, metricsViews map[string]*runtimev1.MetricsViewSpec) (string, *runtimev1.MetricsViewSpec, error) {
diff --git a/web-common/package.json b/web-common/package.json
index bb6c1aafe2e7..cdcdb60d26c3 100644
--- a/web-common/package.json
+++ b/web-common/package.json
@@ -79,6 +79,7 @@
"jspdf": "^2.5.2",
"lucide-svelte": "^0.298.0",
"luxon": "^3.5.0",
+ "mapbox-gl": "^3.0.0",
"marked": "^16.4.0",
"match-sorter": "^6.3.1",
"memoizee": "^0.4.17",
diff --git a/web-common/src/features/canvas/AddComponentDropdown.svelte b/web-common/src/features/canvas/AddComponentDropdown.svelte
index 449ff7d2ca6a..63715d94d253 100644
--- a/web-common/src/features/canvas/AddComponentDropdown.svelte
+++ b/web-common/src/features/canvas/AddComponentDropdown.svelte
@@ -13,6 +13,7 @@
import BigNumberIcon from "./icons/BigNumberIcon.svelte";
import ChartIcon from "./icons/ChartIcon.svelte";
import LeaderboardIcon from "./icons/LeaderboardIcon.svelte";
+ import MapIcon from "./icons/MapIcon.svelte";
import TableIcon from "./icons/TableIcon.svelte";
import TextIcon from "./icons/TextIcon.svelte";
type MainMenuItem = {
@@ -29,6 +30,7 @@
{ id: "kpi_grid", label: m.canvas_kpi(), icon: BigNumberIcon },
{ id: "leaderboard", label: m.canvas_leaderboard(), icon: LeaderboardIcon },
{ id: "image", label: m.canvas_image(), icon: ChartIcon },
+ { id: "map", label: m.canvas_map(), icon: MapIcon },
];
export let disabled = false;
diff --git a/web-common/src/features/canvas/components/map/CanvasMap.svelte b/web-common/src/features/canvas/components/map/CanvasMap.svelte
new file mode 100644
index 000000000000..6545747b342f
--- /dev/null
+++ b/web-common/src/features/canvas/components/map/CanvasMap.svelte
@@ -0,0 +1,403 @@
+
+
+
+
+
diff --git a/web-common/src/features/canvas/components/map/MapColorSelector.svelte b/web-common/src/features/canvas/components/map/MapColorSelector.svelte
new file mode 100644
index 000000000000..23d443ec357e
--- /dev/null
+++ b/web-common/src/features/canvas/components/map/MapColorSelector.svelte
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+{#if selected === 0}
+
+ {#key `${isThemeModeDark}-${resolvedTheme.primary.hex()}-${resolvedTheme.secondary.hex()}`}
+
+ {/key}
+
+{:else}
+
+
+
+
+
+{/if}
diff --git a/web-common/src/features/canvas/components/map/color-utils.ts b/web-common/src/features/canvas/components/map/color-utils.ts
new file mode 100644
index 000000000000..511cee6dd5c3
--- /dev/null
+++ b/web-common/src/features/canvas/components/map/color-utils.ts
@@ -0,0 +1,146 @@
+import type { ColorRangeMapping } from "@rilldata/web-common/features/components/charts/types";
+import {
+ getSequentialColorsAsHex,
+ getDivergingColorsAsHex,
+} from "@rilldata/web-common/features/themes/palette-store";
+import chroma, { type Color } from "chroma-js";
+import * as d3sc from "d3-scale-chromatic";
+
+type MapTheme = { primary: Color; secondary: Color };
+
+/**
+ * Mapping of Vega/D3 scheme names to d3-scale-chromatic interpolators.
+ */
+const schemeInterpolators: Record string) | undefined> =
+ {
+ tealblues: d3sc.interpolateGnBu,
+ viridis: d3sc.interpolateViridis,
+ magma: d3sc.interpolateMagma,
+ inferno: d3sc.interpolateInferno,
+ plasma: d3sc.interpolatePlasma,
+ cividis: d3sc.interpolateCividis,
+ blues: d3sc.interpolateBlues,
+ teals: d3sc.interpolateGnBu,
+ greens: d3sc.interpolateGreens,
+ greys: d3sc.interpolateGreys,
+ oranges: d3sc.interpolateOranges,
+ purples: d3sc.interpolatePurples,
+ reds: d3sc.interpolateReds,
+ turbo: d3sc.interpolateTurbo,
+ spectral: d3sc.interpolateSpectral,
+ };
+
+/**
+ * Resolves a theme color reference ("primary"/"secondary") or hex to a hex string.
+ */
+export function resolveStaticColor(color: string, theme: MapTheme): string {
+ if (color === "primary") return theme.primary.hex();
+ if (color === "secondary") return theme.secondary.hex();
+ return color;
+}
+
+/**
+ * Resolves a ColorRangeMapping to an array of hex color strings
+ * for generating Mapbox interpolation stops.
+ */
+export function resolveColorRange(
+ colorRange: ColorRangeMapping,
+ theme: MapTheme,
+ steps = 7,
+): string[] {
+ if (colorRange.mode === "gradient") {
+ const start = resolveStaticColor(colorRange.start, theme);
+ const end = resolveStaticColor(colorRange.end, theme);
+ return chroma.scale([start, end]).mode("lab").colors(steps);
+ }
+
+ // Scheme mode
+ if (colorRange.scheme === "sequential") {
+ return getSequentialColorsAsHex();
+ }
+ if (colorRange.scheme === "diverging") {
+ return getDivergingColorsAsHex();
+ }
+
+ // Named D3/Vega scheme
+ const interpolator = schemeInterpolators[colorRange.scheme as string];
+ if (interpolator) {
+ return Array.from({ length: steps }, (_, i) => {
+ const t = i / (steps - 1);
+ return chroma(interpolator(t)).hex();
+ });
+ }
+
+ // Fallback to tealblues
+ return Array.from({ length: steps }, (_, i) => {
+ const t = i / (steps - 1);
+ return chroma(d3sc.interpolateGnBu(t)).hex();
+ });
+}
+
+/**
+ * Computes the min and max values of a numeric property across GeoJSON features.
+ */
+export function computeMinMax(
+ features: GeoJSON.Feature[],
+ property: string,
+): [number, number] {
+ let min = Infinity;
+ let max = -Infinity;
+ for (const f of features) {
+ const val = f.properties?.[property];
+ if (typeof val === "number" && isFinite(val)) {
+ min = Math.min(min, val);
+ max = Math.max(max, val);
+ }
+ }
+ if (min === Infinity) return [0, 1];
+ if (min === max) return [min, min + 1];
+ return [min, max];
+}
+
+/**
+ * Builds a Mapbox GL interpolate expression for coloring features
+ * by a numeric property using the given color stops.
+ */
+export function buildColorExpression(
+ property: string,
+ min: number,
+ max: number,
+ colors: string[],
+): unknown[] {
+ const stops: (number | string)[] = [];
+ for (let i = 0; i < colors.length; i++) {
+ const t = min + (i / (colors.length - 1)) * (max - min);
+ stops.push(t, colors[i]);
+ }
+
+ return [
+ "interpolate",
+ ["linear"],
+ ["coalesce", ["get", property], min],
+ ...stops,
+ ];
+}
+
+/**
+ * Builds a Mapbox GL interpolate expression for sizing circles
+ * by a numeric property.
+ */
+export function buildSizeExpression(
+ property: string,
+ min: number,
+ max: number,
+ minRadius = 4,
+ maxRadius = 20,
+): unknown[] {
+ return [
+ "interpolate",
+ ["linear"],
+ ["coalesce", ["get", property], min],
+ min,
+ minRadius,
+ max,
+ maxRadius,
+ ];
+}
diff --git a/web-common/src/features/canvas/components/map/index.ts b/web-common/src/features/canvas/components/map/index.ts
new file mode 100644
index 000000000000..16a9cbc031d8
--- /dev/null
+++ b/web-common/src/features/canvas/components/map/index.ts
@@ -0,0 +1,132 @@
+import { BaseCanvasComponent } from "@rilldata/web-common/features/canvas/components/BaseCanvasComponent";
+import {
+ getCommonOptions,
+ getFilterOptions,
+} from "@rilldata/web-common/features/canvas/components/util";
+import type { InputParams } from "@rilldata/web-common/features/canvas/inspector/types";
+import type { ColorRangeMapping } from "@rilldata/web-common/features/components/charts/types";
+import {
+ type V1MetricsViewSpec,
+ type V1Resource,
+ MetricsViewSpecDimensionType,
+} from "@rilldata/web-common/runtime-client";
+import type { CanvasEntity, ComponentPath } from "../../stores/canvas-entity";
+import type {
+ CanvasComponentType,
+ ComponentCommonProperties,
+ ComponentFilterProperties,
+} from "../types";
+import CanvasMap from "./CanvasMap.svelte";
+
+export { default as CanvasMap } from "./CanvasMap.svelte";
+
+export interface MapColorConfig {
+ measure: string;
+ colorRange?: ColorRangeMapping;
+}
+
+export interface MapSpec
+ extends ComponentCommonProperties,
+ ComponentFilterProperties {
+ metrics_view: string;
+ geo_dimension: string;
+ color: string | MapColorConfig;
+ size_measure?: string;
+ tooltip_dimension?: string;
+}
+
+export function isMapColorConfig(
+ color: string | MapColorConfig | undefined,
+): color is MapColorConfig {
+ return typeof color === "object" && color !== null && "measure" in color;
+}
+
+export class MapComponent extends BaseCanvasComponent {
+ minSize = { width: 4, height: 4 };
+ defaultSize = { width: 6, height: 4 };
+ resetParams = ["geo_dimension", "color", "size_measure", "tooltip_dimension"];
+ type: CanvasComponentType = "map";
+ component = CanvasMap;
+ _isPolygonMode = false;
+
+ constructor(resource: V1Resource, parent: CanvasEntity, path: ComponentPath) {
+ const defaultSpec: MapSpec = {
+ metrics_view: "",
+ geo_dimension: "",
+ color: "primary",
+ };
+ super(resource, parent, path, defaultSpec);
+ }
+
+ isValid(spec: MapSpec): boolean {
+ return (
+ typeof spec.metrics_view === "string" &&
+ typeof spec.geo_dimension === "string" &&
+ spec.geo_dimension !== ""
+ );
+ }
+
+ inputParams(): InputParams {
+ const inputParams: InputParams = {
+ options: {
+ metrics_view: { type: "metrics", label: "Metrics view" },
+ geo_dimension: {
+ type: "dimension",
+ label: "Geo dimension",
+ meta: { geoOnly: true },
+ },
+ color: {
+ type: "map_color",
+ label: "Color",
+ },
+ size_measure: {
+ type: "measure",
+ optional: true,
+ label: "Size measure",
+ showInUI: !this._isPolygonMode,
+ meta: { isRemovable: true },
+ },
+ tooltip_dimension: {
+ type: "dimension",
+ optional: true,
+ label: "Tooltip dimension",
+ meta: { isRemovable: true },
+ },
+ ...getCommonOptions(),
+ },
+ filter: getFilterOptions(),
+ };
+
+ return inputParams;
+ }
+
+ static newComponentSpec(
+ metricsViewName: string,
+ metricsViewSpec: V1MetricsViewSpec | undefined,
+ ): MapSpec {
+ // Find first geo dimension
+ const geoDimension = metricsViewSpec?.dimensions?.find(
+ (d) => d.type === MetricsViewSpecDimensionType.DIMENSION_TYPE_GEOSPATIAL,
+ );
+ const geoDimensionName = geoDimension?.name || "";
+
+ // Get first measure for color if available
+ const firstMeasure = metricsViewSpec?.measures?.[0];
+ const colorMeasure = firstMeasure?.name;
+
+ return {
+ metrics_view: metricsViewName,
+ geo_dimension: geoDimensionName,
+ color: colorMeasure
+ ? {
+ measure: colorMeasure,
+ colorRange: { mode: "scheme", scheme: "tealblues" },
+ }
+ : "primary",
+ };
+ }
+
+ get isBuilderMode(): boolean {
+ return !!this.parent.fileArtifact;
+ }
+}
diff --git a/web-common/src/features/canvas/components/map/map-utils.ts b/web-common/src/features/canvas/components/map/map-utils.ts
new file mode 100644
index 000000000000..1b0f776ed384
--- /dev/null
+++ b/web-common/src/features/canvas/components/map/map-utils.ts
@@ -0,0 +1,261 @@
+import mapboxgl from "mapbox-gl";
+import type { V1MetricsViewAggregationResponseDataItem } from "@rilldata/web-common/runtime-client";
+import {
+ mouseLocationToBoundingRect,
+ placeElement,
+} from "@rilldata/web-common/lib/place-element";
+import { justEnoughPrecision } from "@rilldata/web-common/lib/formatters";
+
+// ── GeoJSON transformation ──────────────────────────────────────
+
+interface TransformOptions {
+ geoDimension: string;
+ colorMeasure: string | null;
+ sizeMeasure: string | undefined;
+ tooltipDimension: string | undefined;
+}
+
+export function transformToGeoJSON(
+ data: V1MetricsViewAggregationResponseDataItem[],
+ opts: TransformOptions,
+): GeoJSON.FeatureCollection {
+ const features: GeoJSON.Feature[] = [];
+
+ for (const row of data) {
+ const geoValue = row[opts.geoDimension];
+ if (!geoValue) continue;
+
+ let geometry: GeoJSON.Geometry | null = null;
+
+ if (typeof geoValue === "string") {
+ try {
+ const parsed = JSON.parse(geoValue);
+ if (parsed?.type && parsed?.coordinates) {
+ geometry = parsed as GeoJSON.Geometry;
+ } else if (parsed?.type === "Feature" && parsed?.geometry) {
+ geometry = parsed.geometry as GeoJSON.Geometry;
+ }
+ } catch {
+ continue;
+ }
+ } else if (Array.isArray(geoValue)) {
+ // DuckDB spatial types are serialized as [x, y] = [lon, lat],
+ // which is already the order GeoJSON/Mapbox expect — pass through.
+ if (
+ geoValue.length === 2 &&
+ typeof geoValue[0] === "number" &&
+ typeof geoValue[1] === "number"
+ ) {
+ const [lon, lat] = geoValue as [number, number];
+ geometry = { type: "Point", coordinates: [lon, lat] };
+ } else if (Array.isArray(geoValue[0]) && Array.isArray(geoValue[0][0])) {
+ geometry = {
+ type: "Polygon",
+ coordinates: geoValue as number[][][],
+ };
+ }
+ }
+
+ if (!geometry) continue;
+
+ const properties: Record = {};
+ if (opts.colorMeasure && row[opts.colorMeasure] != null) {
+ properties[opts.colorMeasure] = Number(row[opts.colorMeasure]);
+ }
+ if (opts.sizeMeasure && row[opts.sizeMeasure] != null) {
+ properties[opts.sizeMeasure] = Number(row[opts.sizeMeasure]);
+ }
+ if (opts.tooltipDimension && row[opts.tooltipDimension] != null) {
+ properties[opts.tooltipDimension] = row[opts.tooltipDimension];
+ }
+
+ features.push({ type: "Feature", geometry, properties });
+ }
+
+ return { type: "FeatureCollection", features };
+}
+
+// ── Bounds calculation ──────────────────────────────────────────
+
+function extendBoundsWithCoord(bounds: mapboxgl.LngLatBounds, coord: number[]) {
+ const [lng, lat] = coord;
+ if (
+ typeof lng === "number" &&
+ typeof lat === "number" &&
+ lng >= -180 &&
+ lng <= 180 &&
+ lat >= -90 &&
+ lat <= 90
+ ) {
+ bounds.extend([lng, lat]);
+ }
+}
+
+export function calculateBounds(
+ features: GeoJSON.Feature[],
+): mapboxgl.LngLatBounds | null {
+ if (features.length === 0) return null;
+
+ const bounds = new mapboxgl.LngLatBounds();
+ let hasValidCoord = false;
+
+ for (const feature of features) {
+ const geom = feature.geometry;
+ switch (geom.type) {
+ case "Point":
+ extendBoundsWithCoord(bounds, geom.coordinates);
+ hasValidCoord = true;
+ break;
+ case "MultiPoint":
+ case "LineString":
+ for (const coord of geom.coordinates) {
+ extendBoundsWithCoord(bounds, coord);
+ }
+ hasValidCoord = true;
+ break;
+ case "Polygon":
+ case "MultiLineString":
+ for (const ring of geom.coordinates) {
+ for (const coord of ring) {
+ extendBoundsWithCoord(bounds, coord);
+ }
+ }
+ hasValidCoord = true;
+ break;
+ case "MultiPolygon":
+ for (const polygon of geom.coordinates) {
+ for (const ring of polygon) {
+ for (const coord of ring) {
+ extendBoundsWithCoord(bounds, coord);
+ }
+ }
+ }
+ hasValidCoord = true;
+ break;
+ }
+ }
+
+ return hasValidCoord ? bounds : null;
+}
+
+// ── Polygon detection ───────────────────────────────────────────
+
+export function detectPolygonMode(
+ rows: V1MetricsViewAggregationResponseDataItem[],
+ geoDimension: string,
+): boolean {
+ if (!rows.length || !geoDimension) return false;
+ return rows.some((row) => {
+ const v = row[geoDimension];
+ if (typeof v === "string") {
+ try {
+ const parsed = JSON.parse(v);
+ const t = parsed?.type ?? parsed?.geometry?.type;
+ return t === "Polygon" || t === "MultiPolygon";
+ } catch {
+ return false;
+ }
+ }
+ return Array.isArray(v) && Array.isArray(v[0]) && Array.isArray(v[0][0]);
+ });
+}
+
+// ── Tooltip ─────────────────────────────────────────────────────
+
+const MAP_TOOLTIP_ID = "rill-map-tooltip";
+
+function escapeHTML(value: unknown): string {
+ return String(value).replace(/&/g, "&").replace(/ string;
+}
+
+export function buildTooltipHTML(
+ properties: Record | null,
+ ctx: TooltipContext,
+): string | null {
+ if (!properties) return null;
+
+ const { tooltipDimension, colorMeasure: cm, sizeMeasure: sm } = ctx;
+ if (!tooltipDimension && !cm && !sm) return null;
+
+ let html = "";
+
+ if (tooltipDimension && properties[tooltipDimension] != null) {
+ html += `${escapeHTML(properties[tooltipDimension])}
`;
+ }
+
+ const rows: string[] = [];
+ if (cm && properties[cm] != null) {
+ const val =
+ typeof properties[cm] === "number"
+ ? justEnoughPrecision(properties[cm] as number)
+ : String(properties[cm]);
+ rows.push(
+ `| ${escapeHTML(ctx.getDisplayName(cm))} | ${escapeHTML(val)} |
`,
+ );
+ }
+ if (sm && sm !== cm && properties[sm] != null) {
+ const val =
+ typeof properties[sm] === "number"
+ ? justEnoughPrecision(properties[sm] as number)
+ : String(properties[sm]);
+ rows.push(
+ `| ${escapeHTML(ctx.getDisplayName(sm))} | ${escapeHTML(val)} |
`,
+ );
+ }
+
+ if (rows.length > 0) {
+ html += ``;
+ }
+
+ return html || null;
+}
+
+export function showTooltip(
+ event: mapboxgl.MapMouseEvent & { features?: GeoJSON.Feature[] },
+ ctx: TooltipContext,
+) {
+ removeTooltip();
+
+ const feature = event.features?.[0];
+ if (!feature) return;
+
+ const html = buildTooltipHTML(
+ feature.properties as Record | null,
+ ctx,
+ );
+ if (!html) return;
+
+ const el = document.createElement("div");
+ el.setAttribute("id", MAP_TOOLTIP_ID);
+ el.innerHTML = html;
+ document.body.appendChild(el);
+
+ const parentRect = mouseLocationToBoundingRect({
+ x: event.originalEvent.clientX,
+ y: event.originalEvent.clientY,
+ });
+ const elementRect = el.getBoundingClientRect();
+
+ const [leftPos, topPos] = placeElement({
+ location: "right",
+ alignment: "middle",
+ distance: 12,
+ pad: 8,
+ parentPosition: parentRect,
+ elementPosition: elementRect,
+ });
+
+ el.setAttribute("style", `top: ${topPos}px; left: ${leftPos}px`);
+}
+
+export function removeTooltip() {
+ const el = document.getElementById(MAP_TOOLTIP_ID);
+ if (el) el.remove();
+}
diff --git a/web-common/src/features/canvas/components/menu-items.svelte b/web-common/src/features/canvas/components/menu-items.svelte
index a325283e648f..608a7ae97e51 100644
--- a/web-common/src/features/canvas/components/menu-items.svelte
+++ b/web-common/src/features/canvas/components/menu-items.svelte
@@ -2,6 +2,7 @@
import type { ComponentType, SvelteComponent } from "svelte";
import BigNumberIcon from "../icons/BigNumberIcon.svelte";
import ChartIcon from "../icons/ChartIcon.svelte";
+ import MapIcon from "../icons/MapIcon.svelte";
import TableIcon from "../icons/TableIcon.svelte";
import TextIcon from "../icons/TextIcon.svelte";
import type { CanvasComponentType } from "./types";
@@ -19,5 +20,6 @@
{ id: "kpi_grid", label: "KPI", icon: BigNumberIcon },
{ id: "image", label: "Image", icon: ChartIcon },
{ id: "leaderboard", label: "Leaderboard", icon: TableIcon },
+ { id: "map", label: "Map", icon: MapIcon },
];
diff --git a/web-common/src/features/canvas/components/types.ts b/web-common/src/features/canvas/components/types.ts
index feca61fb4c93..702f2383ea42 100644
--- a/web-common/src/features/canvas/components/types.ts
+++ b/web-common/src/features/canvas/components/types.ts
@@ -3,10 +3,11 @@ import type { CircularCanvasChartSpec } from "@rilldata/web-common/features/canv
import type { ScatterPlotCanvasChartSpec } from "@rilldata/web-common/features/canvas/components/charts/variants/ScatterPlotChart";
import type { KPIGridSpec } from "@rilldata/web-common/features/canvas/components/kpi-grid";
import type { ChartType } from "../../components/charts/types";
+import type { CustomChart } from "./charts/custom-chart";
import type { ImageSpec } from "./image";
import type { KPISpec } from "./kpi";
import type { LeaderboardSpec } from "./leaderboard";
-import type { CustomChart } from "./charts/custom-chart";
+import type { MapSpec } from "./map";
import type { MarkdownSpec } from "./markdown";
import type { PivotSpec, TableSpec } from "./pivot";
@@ -19,6 +20,7 @@ export type ComponentWithMetricsView =
| KPISpec
| KPIGridSpec
| LeaderboardSpec
+ | MapSpec
| CustomChart;
export type ComponentSpec = ComponentWithMetricsView | ImageSpec | MarkdownSpec;
@@ -59,6 +61,7 @@ export type CanvasComponentType =
| "pivot"
| "table"
| "leaderboard"
+ | "map"
| "custom_chart";
interface LineChart {
@@ -90,6 +93,9 @@ export interface PivotTemplateT {
export interface TableTemplateT {
table: TableSpec;
}
+export interface MapTemplateT {
+ map: MapSpec;
+}
export type TemplateSpec =
| ChartTemplates
@@ -97,4 +103,5 @@ export type TemplateSpec =
| PivotTemplateT
| MarkdownTemplateT
| ImageTemplateT
- | TableTemplateT;
+ | TableTemplateT
+ | MapTemplateT;
diff --git a/web-common/src/features/canvas/components/util.ts b/web-common/src/features/canvas/components/util.ts
index 688659310e16..d7eda323a614 100644
--- a/web-common/src/features/canvas/components/util.ts
+++ b/web-common/src/features/canvas/components/util.ts
@@ -5,21 +5,35 @@ import {
import { CustomChartComponent } from "@rilldata/web-common/features/canvas/components/charts/custom-chart";
import { CartesianChartComponent } from "@rilldata/web-common/features/canvas/components/charts/variants/CartesianChart";
import { KPIGridComponent } from "@rilldata/web-common/features/canvas/components/kpi-grid";
+import BigNumberIcon from "@rilldata/web-common/features/canvas/icons/BigNumberIcon.svelte";
+import ChartIcon from "@rilldata/web-common/features/canvas/icons/ChartIcon.svelte";
+import LeaderboardIcon from "@rilldata/web-common/features/canvas/icons/LeaderboardIcon.svelte";
+import MapIcon from "@rilldata/web-common/features/canvas/icons/MapIcon.svelte";
+import TableIcon from "@rilldata/web-common/features/canvas/icons/TableIcon.svelte";
+import TextIcon from "@rilldata/web-common/features/canvas/icons/TextIcon.svelte";
import type {
ComponentInputParam,
FilterInputParam,
FilterInputTypes,
} from "@rilldata/web-common/features/canvas/inspector/types";
+import {
+ CHART_CONFIG,
+ type ChartMetadataConfig,
+} from "@rilldata/web-common/features/components/charts/config.ts";
+import { getFieldsForSpec } from "@rilldata/web-common/features/components/charts/data-provider.ts";
+import type { ChartSpec } from "@rilldata/web-common/features/components/charts/types.ts";
import {
type V1ComponentSpec,
type V1MetricsViewSpec,
type V1ResolveCanvasResponseResolvedComponents,
type V1Resource,
} from "@rilldata/web-common/runtime-client";
+import { readable } from "svelte/store";
import type { CanvasEntity, ComponentPath } from "../stores/canvas-entity";
import type { BaseCanvasComponent } from "./BaseCanvasComponent";
import { ImageComponent } from "./image";
import { LeaderboardComponent } from "./leaderboard";
+import { MapComponent } from "./map";
import { MarkdownCanvasComponent } from "./markdown";
import { PivotCanvasComponent } from "./pivot";
import type {
@@ -27,18 +41,6 @@ import type {
ComponentCommonProperties,
ComponentSpec,
} from "./types";
-import ChartIcon from "@rilldata/web-common/features/canvas/icons/ChartIcon.svelte";
-import TableIcon from "@rilldata/web-common/features/canvas/icons/TableIcon.svelte";
-import TextIcon from "@rilldata/web-common/features/canvas/icons/TextIcon.svelte";
-import BigNumberIcon from "@rilldata/web-common/features/canvas/icons/BigNumberIcon.svelte";
-import LeaderboardIcon from "@rilldata/web-common/features/canvas/icons/LeaderboardIcon.svelte";
-import {
- CHART_CONFIG,
- type ChartMetadataConfig,
-} from "@rilldata/web-common/features/components/charts/config.ts";
-import { readable } from "svelte/store";
-import { getFieldsForSpec } from "@rilldata/web-common/features/components/charts/data-provider.ts";
-import type { ChartSpec } from "@rilldata/web-common/features/components/charts/types.ts";
import { m } from "@rilldata/web-common/lib/i18n/gen/messages";
@@ -119,6 +121,7 @@ const NON_CHART_TYPES = [
"table",
"pivot",
"leaderboard",
+ "map",
"custom_chart",
] as const;
const ALL_COMPONENT_TYPES = [...CHART_TYPES, ...NON_CHART_TYPES] as const;
@@ -151,6 +154,7 @@ const baseComponentMap = {
leaderboard: LeaderboardComponent,
table: PivotCanvasComponent,
pivot: PivotCanvasComponent,
+ map: MapComponent,
custom_chart: CustomChartComponent,
} as const;
const IconMap = {
@@ -158,6 +162,7 @@ const IconMap = {
kpi_grid: BigNumberIcon,
leaderboard: LeaderboardIcon,
table: TableIcon,
+ map: MapIcon,
};
const chartComponentMap = Object.fromEntries(
@@ -177,6 +182,7 @@ const baseDisplayMap = {
pivot: "Pivot",
image: "Image",
leaderboard: "Leaderboard",
+ map: "Map",
custom_chart: "Custom Chart",
} as const;
diff --git a/web-common/src/features/canvas/icons/MapIcon.svelte b/web-common/src/features/canvas/icons/MapIcon.svelte
new file mode 100644
index 000000000000..65499810feb0
--- /dev/null
+++ b/web-common/src/features/canvas/icons/MapIcon.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/web-common/src/features/canvas/inspector/ParamMapper.svelte b/web-common/src/features/canvas/inspector/ParamMapper.svelte
index 0eddaf54db78..fd53e8dc5ae2 100644
--- a/web-common/src/features/canvas/inspector/ParamMapper.svelte
+++ b/web-common/src/features/canvas/inspector/ParamMapper.svelte
@@ -16,6 +16,7 @@
import MarkSelector from "./chart/MarkSelector.svelte";
import MetricsSQLInput from "./chart/MetricsSQLInput.svelte";
import PositionalFieldConfig from "./chart/PositionalFieldConfig.svelte";
+ import MapColorSelector from "../components/map/MapColorSelector.svelte";
import ComparisonInput from "./ComparisonInput.svelte";
import MultiFieldFormatInput from "./fields/MultiFieldFormatInput.svelte";
import MultiFieldInput from "./fields/MultiFieldInput.svelte";
@@ -113,9 +114,14 @@
id={key}
type={config.type}
selectedItem={localParamValues[key]}
+ geoOnly={config.meta?.geoOnly ?? false}
+ isRemovable={config.meta?.isRemovable ?? false}
onSelect={(field) => {
component.updateProperty(key, field);
}}
+ onRemove={() => {
+ component.updateProperty(key, undefined);
+ }}
/>
@@ -334,6 +340,17 @@
component.updateProperty(key, updatedConfig);
}}
/>
+
+ {:else if metricsView && config.type === "map_color"}
+ {
+ localParamValues[key] = updatedConfig;
+ component.updateProperty(key, updatedConfig);
+ }}
+ />
{/if}
{/if}
diff --git a/web-common/src/features/canvas/inspector/fields/SingleFieldInput.svelte b/web-common/src/features/canvas/inspector/fields/SingleFieldInput.svelte
index 1e6a4b87ca97..c69553083e28 100644
--- a/web-common/src/features/canvas/inspector/fields/SingleFieldInput.svelte
+++ b/web-common/src/features/canvas/inspector/fields/SingleFieldInput.svelte
@@ -19,6 +19,7 @@
export let searchableItems: string[] | undefined = undefined;
export let excludedValues: string[] | undefined = undefined;
export let isRemovable = false;
+ export let geoOnly = false;
export let onSelect: (item: string, displayName: string) => void = () => {};
export let onRemove: () => void = () => {};
@@ -42,6 +43,7 @@
searchableItems,
searchValue,
effectiveExcludedValues,
+ geoOnly,
);
diff --git a/web-common/src/features/canvas/inspector/selectors.ts b/web-common/src/features/canvas/inspector/selectors.ts
index 42f249106f5b..cc617f714111 100644
--- a/web-common/src/features/canvas/inspector/selectors.ts
+++ b/web-common/src/features/canvas/inspector/selectors.ts
@@ -7,7 +7,10 @@ import {
import type { FileArtifact } from "@rilldata/web-common/features/entity-management/file-artifact";
import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config";
import { isGrainBigger } from "@rilldata/web-common/lib/time/grains";
-import { V1TimeGrain } from "@rilldata/web-common/runtime-client";
+import {
+ V1TimeGrain,
+ MetricsViewSpecDimensionType,
+} from "@rilldata/web-common/runtime-client";
import { derived } from "svelte/store";
import { parseDocument } from "yaml";
@@ -25,6 +28,7 @@ export function useMetricFieldData(
searchableItems: string[] | undefined = undefined,
searchValue = "",
excludedValues: string[] | undefined = undefined,
+ geoOnly: boolean = false,
) {
const { metricsView, timeManager } = ctx.canvasEntity;
@@ -54,13 +58,20 @@ export function useMetricFieldData(
);
}
if (type.includes("dimension")) {
+ const filteredDimensions = geoOnly
+ ? dimensions.filter(
+ (d) =>
+ d.type ===
+ MetricsViewSpecDimensionType.DIMENSION_TYPE_GEOSPATIAL,
+ )
+ : dimensions;
items = items.concat(
- dimensions?.map((d) => d.name || (d.column as string)) ?? [],
+ filteredDimensions?.map((d) => d.name || (d.column as string)) ?? [],
);
Object.assign(
displayMap,
Object.fromEntries(
- dimensions.map((item) => [
+ filteredDimensions.map((item) => [
item.name || (item.column as string),
{ label: getDimensionDisplayName(item), type: "dimension" },
]),
diff --git a/web-common/src/features/canvas/inspector/types.ts b/web-common/src/features/canvas/inspector/types.ts
index e6dec512c553..d22773bed42e 100644
--- a/web-common/src/features/canvas/inspector/types.ts
+++ b/web-common/src/features/canvas/inspector/types.ts
@@ -17,6 +17,7 @@ type CustomInputTypes =
| "rill_time"
| "sparkline"
| "comparison_options"
+ | "map_color"
| "vega_spec"
| "switcher_tab"
| "ai_generate"
@@ -90,6 +91,11 @@ export interface ComponentInputParam {
allowedTypes?: FieldType[]; // Specify which field types are allowed for multi-field selection
defaultAlignment?: ComponentAlignment;
chartFieldInput?: ChartFieldInput;
+ /**
+ * Marks a measure/dimension field as removable, showing a remove button on
+ * the selected chip. On removal the property is cleared from the spec.
+ */
+ isRemovable?: boolean;
layout?: "default" | "grouped";
/**
* If true, the boolean input will be inverted. This is useful when true
diff --git a/web-common/src/features/canvas/layout-util.ts b/web-common/src/features/canvas/layout-util.ts
index 065017658888..537f45ec1d4a 100644
--- a/web-common/src/features/canvas/layout-util.ts
+++ b/web-common/src/features/canvas/layout-util.ts
@@ -32,6 +32,7 @@ export const initialHeights: Record = {
table: 300,
pivot: 300,
leaderboard: 300,
+ map: 400,
};
// Minimum heights a component can shrink to, when smaller than its initial
diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json
index c5ee1e246067..1e83f5bee0d1 100644
--- a/web-common/src/lib/i18n/messages/en.json
+++ b/web-common/src/lib/i18n/messages/en.json
@@ -423,6 +423,7 @@
"canvas_kpi": "KPI",
"canvas_label_angle": "Label angle",
"canvas_leaderboard": "Leaderboard",
+ "canvas_map": "Map",
"canvas_left_y_axis_label": "Left Y-Axis",
"canvas_legend_bottom": "Bottom",
"canvas_legend_left": "Left",
diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json
index da2ec9d5d30c..587ddfd2993d 100644
--- a/web-common/src/lib/i18n/messages/es.json
+++ b/web-common/src/lib/i18n/messages/es.json
@@ -423,6 +423,7 @@
"canvas_kpi": "KPI",
"canvas_label_angle": "Ángulo de etiqueta",
"canvas_leaderboard": "Ranking",
+ "canvas_map": "Mapa",
"canvas_left_y_axis_label": "Eje Y izquierdo",
"canvas_legend_bottom": "Abajo",
"canvas_legend_left": "Izquierda",