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 += `${rows.join("")}
`; + } + + 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",