/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps * * The canonical property layer: price/value pills + clusters, rendered * entirely by MapLibre (no DOM markers). One implementation for every * Groupe Ka app; colors come from the app theme, states from feature-state. */ import type { CircleLayerSpecification, ExpressionSpecification, Map as MapboxMap, SymbolLayerSpecification, } from "mapbox-gl"; import type { KaMapTheme } from "../theming/tokens.js"; import { markerTokens } from "../theming/tokens.js"; export const PROPERTY_SOURCE_ID = "ka-properties"; export const LAYER_IDS = { clusterRing: "ka-cluster-ring", clusters: "ka-clusters", clusterCount: "ka-cluster-count", clusterValue: "ka-cluster-value", pointDot: "ka-point-dot", pointPill: "ka-point-pill", } as const; /** Compact fr-CA price expression usable inside MapLibre layers. * Mirrors utils/format.formatCompactPrice for the common ranges. * Non-numeric/absent values render as "" (style-spec strict typing: * numeric operators get `to-number`-coerced operands). */ export function compactPriceExpression( field: ExpressionSpecification, ): ExpressionSpecification { const num: ExpressionSpecification = ["to-number", field]; // Arrondis faits DANS l'expression : les options *-fraction-digits de // number-format ne sont pas fiables sur toutes les versions du moteur. const dollars: ExpressionSpecification = ["round", num]; const thousands: ExpressionSpecification = ["round", ["/", num, 1000]]; const millions: ExpressionSpecification = [ "/", ["round", ["*", ["/", num, 1000000], 100]], 100, ]; return [ "case", ["!=", ["typeof", field], "number"], "", ["<", num, 10000], ["concat", ["number-format", dollars, { locale: "fr-CA" }], " $"], ["<", num, 999500], ["concat", ["number-format", thousands, { locale: "fr-CA" }], " k$"], ["concat", ["number-format", millions, { locale: "fr-CA" }], " M$"], ] as ExpressionSpecification; } const PILL_FAMILIES = ["sale", "rent", "valuation", "highlight"] as const; type PillFamily = (typeof PILL_FAMILIES)[number]; /** Image id for a family pill, optionally in its selected state. */ export function pillImageId(family: PillFamily, selected = false): string { return `ka-pill-${family}${selected ? "-sel" : ""}`; } /** * Register the stretchable price-pill backgrounds, one per marker family × * state, pre-colored from the app theme. Canvas raster (non-SDF) because * MapLibre does not support nine-slice stretching on SDF icons. */ export function registerPillImages(map: MapboxMap, theme: KaMapTheme): void { const DPR = 2; const W = 64; const H = 26; // R < H/2 - 1 obligatoire : la bande stretchY est [(R+1)·DPR, (H-R-1)·DPR] // et Mapbox exige début < fin — R=12.5 sur H=26 inverse la bande (image // rejetée : « invalid stretchY value », pilules invisibles). const R = 11.5; const TAIL = 5; // v2 « premium » : pilule plus fine, ombre diffuse légère, liseré hairline // (fini l'effet gros bouton opaque posé sur la carte). const draw = (background: string, border: string): ImageData => { const canvas = document.createElement("canvas"); canvas.width = W * DPR; canvas.height = (H + TAIL + 4) * DPR; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas 2D indisponible"); ctx.scale(DPR, DPR); ctx.shadowColor = "rgba(12, 14, 18, 0.20)"; ctx.shadowBlur = 7; ctx.shadowOffsetY = 2; // corps de la pastille ctx.beginPath(); ctx.roundRect(1, 1, W - 2, H - 2, R); ctx.fillStyle = background; ctx.fill(); // pointe vers la coordonnée ctx.shadowColor = "transparent"; ctx.beginPath(); ctx.moveTo(W / 2 - 4.5, H - 1.5); ctx.lineTo(W / 2, H + TAIL - 1); ctx.lineTo(W / 2 + 4.5, H - 1.5); ctx.closePath(); ctx.fillStyle = background; ctx.fill(); ctx.beginPath(); ctx.roundRect(1, 1, W - 2, H - 2, R); ctx.strokeStyle = border; ctx.lineWidth = 1.1; ctx.stroke(); return ctx.getImageData(0, 0, canvas.width, canvas.height); }; for (const family of PILL_FAMILIES) { const tokens = markerTokens(theme, family); const variants: [string, string, string][] = [ [pillImageId(family), tokens.background, tokens.halo], [pillImageId(family, true), tokens.selectedBackground, tokens.halo], ]; for (const [id, background, border] of variants) { if (map.hasImage(id)) continue; map.addImage(id, draw(background, border), { pixelRatio: DPR, stretchX: [[(R + 1) * DPR, (W - R - 1) * DPR]], stretchY: [[(R + 1) * DPR, (H - R - 1) * DPR]], content: [8 * DPR, 5 * DPR, (W - 8) * DPR, (H - 5) * DPR], }); } } } /** Family selector shared by icon and color expressions. */ function familyCase(byFamily: Record): ExpressionSpecification { return [ "case", ["==", ["get", "highlight"], 1], byFamily.highlight, ["==", ["get", "kind"], "valuation"], byFamily.valuation, ["==", ["get", "listingType"], "rent"], byFamily.rent, byFamily.sale, ] as ExpressionSpecification; } /** * icon-image expression (layout ⇒ feature-state interdit) : la sélection est * réinjectée par KaMap via setLayoutProperty à chaque changement. */ export function pillIconExpression( selectedId: string | null, ): ExpressionSpecification { const base = familyCase({ sale: pillImageId("sale"), rent: pillImageId("rent"), valuation: pillImageId("valuation"), highlight: pillImageId("highlight"), }); if (selectedId === null) return base; const selected = familyCase({ sale: pillImageId("sale", true), rent: pillImageId("rent", true), valuation: pillImageId("valuation", true), highlight: pillImageId("highlight", true), }); return [ "case", ["==", ["get", "id"], selectedId], selected, base, ] as ExpressionSpecification; } interface FamilyColors { background: ExpressionSpecification; text: ExpressionSpecification; halo: ExpressionSpecification; } /** Data-driven colors: family (sale/rent/valuation/highlight) × state. */ function familyColorExpressions(theme: KaMapTheme): FamilyColors { const sale = markerTokens(theme, "sale"); const rent = markerTokens(theme, "rent"); const valuation = markerTokens(theme, "valuation"); const highlight = markerTokens(theme, "highlight"); const isHighlight: ExpressionSpecification = ["==", ["get", "highlight"], 1]; const isValuation: ExpressionSpecification = ["==", ["get", "kind"], "valuation"]; const isRent: ExpressionSpecification = ["==", ["get", "listingType"], "rent"]; const selected: ExpressionSpecification = ["boolean", ["feature-state", "selected"], false]; const hovered: ExpressionSpecification = ["boolean", ["feature-state", "hovered"], false]; const active: ExpressionSpecification = ["any", selected, hovered]; const pick = (key: keyof typeof sale): ExpressionSpecification => [ "case", isHighlight, highlight[key], isValuation, valuation[key], isRent, rent[key], sale[key], ] as ExpressionSpecification; return { background: [ "case", active, pick("selectedBackground"), pick("background"), ] as ExpressionSpecification, text: ["case", active, pick("selectedText"), pick("text")] as ExpressionSpecification, halo: pick("halo"), }; } /** All property/cluster layer specifications for the given theme. */ export function buildPropertyLayers( theme: KaMapTheme, sourceId: string = PROPERTY_SOURCE_ID, opts?: { /** N minimal d'items pour afficher la valeur sous la bulle — cartes * denses (Immo-Ka) : éviter un ≈prix sous chaque micro-bulle. */ valueMinCount?: number; }, ): (SymbolLayerSpecification | CircleLayerSpecification)[] { const colors = familyColorExpressions(theme); const dimmed: ExpressionSpecification = ["boolean", ["feature-state", "dimmed"], false]; // « Vu » : annonce déjà consultée — atténuée mais lisible ; la sélection // et le survol reprennent toujours la pleine opacité. const seen: ExpressionSpecification = ["boolean", ["feature-state", "seen"], false]; const activeState: ExpressionSpecification = [ "any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "hovered"], false], ]; const stateOpacity: ExpressionSpecification = [ "case", dimmed, 0.35, ["all", seen, ["!", activeState]], 0.62, 1, ] as ExpressionSpecification; // v2 : bulles compactes et légères — halo diffus + pastille — au lieu des // gros disques opaques. La valeur indicative s'affiche SOUS la bulle. const clusterRadius: ExpressionSpecification = [ "step", ["get", "point_count"], 12, 10, 14.5, 50, 17, 200, 20, ]; const clusterRing: CircleLayerSpecification = { id: LAYER_IDS.clusterRing, slot: "top", type: "circle", source: sourceId, filter: ["has", "point_count"], paint: { "circle-color": theme.cluster.border, "circle-radius": ["+", clusterRadius, 5] as ExpressionSpecification, "circle-opacity": 0.22, "circle-blur": 0.45, }, }; const clusterCircle: CircleLayerSpecification = { id: LAYER_IDS.clusters, slot: "top", type: "circle", source: sourceId, filter: ["has", "point_count"], paint: { "circle-color": theme.cluster.background, "circle-stroke-color": theme.cluster.border, "circle-stroke-width": 1.5, "circle-radius": clusterRadius, "circle-opacity": 0.96, }, }; const clusterCount: SymbolLayerSpecification = { id: LAYER_IDS.clusterCount, slot: "top", type: "symbol", source: sourceId, filter: ["has", "point_count"], layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"], "text-size": ["step", ["get", "point_count"], 11.5, 50, 12.5], "text-allow-overlap": true, }, paint: { "text-color": theme.cluster.text }, }; // Valeur indicative (moyenne bornée) sous la bulle — texte halo, pas de // deuxième boîte : la carte respire. const clusterValue: SymbolLayerSpecification = { id: LAYER_IDS.clusterValue, slot: "top", type: "symbol", source: sourceId, filter: [ "all", ["has", "point_count"], [">", ["to-number", ["get", "valueCount"]], 0], [">=", ["to-number", ["get", "point_count"]], opts?.valueMinCount ?? 0], ], minzoom: 9, layout: { "text-field": [ "concat", "≈", compactPriceExpression([ "/", ["to-number", ["get", "valueSum"]], ["max", 1, ["to-number", ["get", "valueCount"]]], ] as ExpressionSpecification), ], "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"], "text-size": 10, "text-anchor": "top", "text-offset": [0, 1.55], "text-allow-overlap": true, }, paint: { "text-color": theme.cluster.valueText ?? theme.cluster.border, "text-halo-color": theme.cluster.valueHalo ?? "rgba(255,255,255,0.85)", "text-halo-width": 1.1, "text-opacity": 0.95, }, }; const pointDot: CircleLayerSpecification = { id: LAYER_IDS.pointDot, slot: "top", type: "circle", source: sourceId, filter: ["!", ["has", "point_count"]], paint: { "circle-color": colors.background, "circle-radius": [ "case", ["boolean", ["feature-state", "selected"], false], 5, 3.5, ], "circle-stroke-color": colors.halo, "circle-stroke-width": 1.5, "circle-opacity": stateOpacity, "circle-stroke-opacity": stateOpacity, }, }; const pointPill: SymbolLayerSpecification = { id: LAYER_IDS.pointPill, slot: "top", type: "symbol", source: sourceId, filter: [ "all", ["!", ["has", "point_count"]], ["!=", ["get", "labelValue"], null], ], layout: { "icon-image": pillIconExpression(null), "icon-text-fit": "both", "icon-text-fit-padding": [2.5, 8.5, 7.5, 8.5], "icon-anchor": "bottom", "icon-allow-overlap": false, "icon-optional": false, "text-field": compactPriceExpression(["get", "labelValue"] as ExpressionSpecification), "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"], "text-size": ["interpolate", ["linear"], ["zoom"], 13, 10.5, 17, 12.5], "text-anchor": "bottom", "text-offset": [0, -0.9], "text-allow-overlap": false, "text-optional": false, // feature-state est interdit dans les propriétés layout : le tri se // fait par valeur (les plus chères gagnent les collisions de labels). "symbol-sort-key": [ "-", 10000000, ["to-number", ["coalesce", ["get", "labelValue"], 0]], ], }, paint: { "icon-opacity": stateOpacity, "text-color": colors.text, "text-opacity": stateOpacity, }, }; // Les points nus passent SOUS les bulles de clusters (une pastille claire // ne doit jamais percer le texte d'une bulle voisine) ; les pilules de // prix restent au sommet. return [pointDot, clusterRing, clusterCircle, clusterCount, clusterValue, pointPill]; } /** * Cluster aggregation: running sum/count of labelValue → indicative mean. * `clamp` bounds each point's contribution so a single junk price (a house * listed at 2 M$ in a rental category…) cannot poison a whole bubble. */ export function buildClusterProperties( clamp?: [number, number], ): Record { const raw: unknown = ["to-number", ["coalesce", ["get", "labelValue"], 0]]; const contribution = clamp ? ["min", clamp[1], ["max", clamp[0], raw]] : raw; return { valueSum: ["+", ["case", ["!=", ["get", "labelValue"], null], contribution, 0]], valueCount: ["+", ["case", ["!=", ["get", "labelValue"], null], 1, 0]], // Fourchette de prix du cluster (tooltip au survol). Les valeurs nulles // reçoivent une sentinelle neutre pour chaque agrégat ; dès qu'une vraie // valeur existe dans la bulle, min/max sont exacts. valueMin: ["min", ["case", ["!=", ["get", "labelValue"], null], contribution, 99999999]], valueMax: ["max", ["case", ["!=", ["get", "labelValue"], null], contribution, 0]], }; } /** Default aggregation (no clamp) — kept for direct layer consumers. */ export const CLUSTER_PROPERTIES: Record = buildClusterProperties();