1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Groupe Ka / Ka Maps5 *6 * The canonical property layer: price/value pills + clusters, rendered7 * entirely by MapLibre (no DOM markers). One implementation for every8 * Groupe Ka app; colors come from the app theme, states from feature-state.9 */1011import type {12 CircleLayerSpecification,13 ExpressionSpecification,14 Map as MapboxMap,15 SymbolLayerSpecification,16} from "mapbox-gl";17import type { KaMapTheme } from "../theming/tokens.js";18import { markerTokens } from "../theming/tokens.js";1920export const PROPERTY_SOURCE_ID = "ka-properties";2122export const LAYER_IDS = {23 clusterRing: "ka-cluster-ring",24 clusters: "ka-clusters",25 clusterCount: "ka-cluster-count",26 clusterValue: "ka-cluster-value",27 pointDot: "ka-point-dot",28 pointPill: "ka-point-pill",29} as const;3031/** Compact fr-CA price expression usable inside MapLibre layers.32 * Mirrors utils/format.formatCompactPrice for the common ranges.33 * Non-numeric/absent values render as "" (style-spec strict typing:34 * numeric operators get `to-number`-coerced operands). */35export function compactPriceExpression(36 field: ExpressionSpecification,37): ExpressionSpecification {38 const num: ExpressionSpecification = ["to-number", field];39 // Arrondis faits DANS l'expression : les options *-fraction-digits de40 // number-format ne sont pas fiables sur toutes les versions du moteur.41 const dollars: ExpressionSpecification = ["round", num];42 const thousands: ExpressionSpecification = ["round", ["/", num, 1000]];43 const millions: ExpressionSpecification = [44 "/",45 ["round", ["*", ["/", num, 1000000], 100]],46 100,47 ];48 return [49 "case",50 ["!=", ["typeof", field], "number"],51 "",52 ["<", num, 10000],53 ["concat", ["number-format", dollars, { locale: "fr-CA" }], " $"],54 ["<", num, 999500],55 ["concat", ["number-format", thousands, { locale: "fr-CA" }], " k$"],56 ["concat", ["number-format", millions, { locale: "fr-CA" }], " M$"],57 ] as ExpressionSpecification;58}5960const PILL_FAMILIES = ["sale", "rent", "valuation", "highlight"] as const;61type PillFamily = (typeof PILL_FAMILIES)[number];6263/** Image id for a family pill, optionally in its selected state. */64export function pillImageId(family: PillFamily, selected = false): string {65 return `ka-pill-${family}${selected ? "-sel" : ""}`;66}6768/**69 * Register the stretchable price-pill backgrounds, one per marker family ×70 * state, pre-colored from the app theme. Canvas raster (non-SDF) because71 * MapLibre does not support nine-slice stretching on SDF icons.72 */73export function registerPillImages(map: MapboxMap, theme: KaMapTheme): void {74 const DPR = 2;75 const W = 64;76 const H = 26;77 const R = 12.5;78 const TAIL = 5;7980 // v2 « premium » : pilule plus fine, ombre diffuse légère, liseré hairline81 // (fini l'effet gros bouton opaque posé sur la carte).82 const draw = (background: string, border: string): ImageData => {83 const canvas = document.createElement("canvas");84 canvas.width = W * DPR;85 canvas.height = (H + TAIL + 4) * DPR;86 const ctx = canvas.getContext("2d");87 if (!ctx) throw new Error("Canvas 2D indisponible");88 ctx.scale(DPR, DPR);89 ctx.shadowColor = "rgba(12, 14, 18, 0.20)";90 ctx.shadowBlur = 7;91 ctx.shadowOffsetY = 2;92 // corps de la pastille93 ctx.beginPath();94 ctx.roundRect(1, 1, W - 2, H - 2, R);95 ctx.fillStyle = background;96 ctx.fill();97 // pointe vers la coordonnée98 ctx.shadowColor = "transparent";99 ctx.beginPath();100 ctx.moveTo(W / 2 - 4.5, H - 1.5);101 ctx.lineTo(W / 2, H + TAIL - 1);102 ctx.lineTo(W / 2 + 4.5, H - 1.5);103 ctx.closePath();104 ctx.fillStyle = background;105 ctx.fill();106 ctx.beginPath();107 ctx.roundRect(1, 1, W - 2, H - 2, R);108 ctx.strokeStyle = border;109 ctx.lineWidth = 1.1;110 ctx.stroke();111 return ctx.getImageData(0, 0, canvas.width, canvas.height);112 };113114 for (const family of PILL_FAMILIES) {115 const tokens = markerTokens(theme, family);116 const variants: [string, string, string][] = [117 [pillImageId(family), tokens.background, tokens.halo],118 [pillImageId(family, true), tokens.selectedBackground, tokens.halo],119 ];120 for (const [id, background, border] of variants) {121 if (map.hasImage(id)) continue;122 map.addImage(id, draw(background, border), {123 pixelRatio: DPR,124 stretchX: [[(R + 1) * DPR, (W - R - 1) * DPR]],125 stretchY: [[(R + 1) * DPR, (H - R - 1) * DPR]],126 content: [8 * DPR, 5 * DPR, (W - 8) * DPR, (H - 5) * DPR],127 });128 }129 }130}131132/** Family selector shared by icon and color expressions. */133function familyCase(byFamily: Record<PillFamily, string>): ExpressionSpecification {134 return [135 "case",136 ["==", ["get", "highlight"], 1],137 byFamily.highlight,138 ["==", ["get", "kind"], "valuation"],139 byFamily.valuation,140 ["==", ["get", "listingType"], "rent"],141 byFamily.rent,142 byFamily.sale,143 ] as ExpressionSpecification;144}145146/**147 * icon-image expression (layout ⇒ feature-state interdit) : la sélection est148 * réinjectée par KaMap via setLayoutProperty à chaque changement.149 */150export function pillIconExpression(151 selectedId: string | null,152): ExpressionSpecification {153 const base = familyCase({154 sale: pillImageId("sale"),155 rent: pillImageId("rent"),156 valuation: pillImageId("valuation"),157 highlight: pillImageId("highlight"),158 });159 if (selectedId === null) return base;160 const selected = familyCase({161 sale: pillImageId("sale", true),162 rent: pillImageId("rent", true),163 valuation: pillImageId("valuation", true),164 highlight: pillImageId("highlight", true),165 });166 return [167 "case",168 ["==", ["get", "id"], selectedId],169 selected,170 base,171 ] as ExpressionSpecification;172}173174interface FamilyColors {175 background: ExpressionSpecification;176 text: ExpressionSpecification;177 halo: ExpressionSpecification;178}179180/** Data-driven colors: family (sale/rent/valuation/highlight) × state. */181function familyColorExpressions(theme: KaMapTheme): FamilyColors {182 const sale = markerTokens(theme, "sale");183 const rent = markerTokens(theme, "rent");184 const valuation = markerTokens(theme, "valuation");185 const highlight = markerTokens(theme, "highlight");186187 const isHighlight: ExpressionSpecification = ["==", ["get", "highlight"], 1];188 const isValuation: ExpressionSpecification = ["==", ["get", "kind"], "valuation"];189 const isRent: ExpressionSpecification = ["==", ["get", "listingType"], "rent"];190 const selected: ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];191 const hovered: ExpressionSpecification = ["boolean", ["feature-state", "hovered"], false];192 const active: ExpressionSpecification = ["any", selected, hovered];193194 const pick = (key: keyof typeof sale): ExpressionSpecification =>195 [196 "case",197 isHighlight,198 highlight[key],199 isValuation,200 valuation[key],201 isRent,202 rent[key],203 sale[key],204 ] as ExpressionSpecification;205206 return {207 background: [208 "case",209 active,210 pick("selectedBackground"),211 pick("background"),212 ] as ExpressionSpecification,213 text: ["case", active, pick("selectedText"), pick("text")] as ExpressionSpecification,214 halo: pick("halo"),215 };216}217218/** All property/cluster layer specifications for the given theme. */219export function buildPropertyLayers(220 theme: KaMapTheme,221 sourceId: string = PROPERTY_SOURCE_ID,222): (SymbolLayerSpecification | CircleLayerSpecification)[] {223 const colors = familyColorExpressions(theme);224 const dimmed: ExpressionSpecification = ["boolean", ["feature-state", "dimmed"], false];225 // « Vu » : annonce déjà consultée — atténuée mais lisible ; la sélection226 // et le survol reprennent toujours la pleine opacité.227 const seen: ExpressionSpecification = ["boolean", ["feature-state", "seen"], false];228 const activeState: ExpressionSpecification = [229 "any",230 ["boolean", ["feature-state", "selected"], false],231 ["boolean", ["feature-state", "hovered"], false],232 ];233 const stateOpacity: ExpressionSpecification = [234 "case",235 dimmed,236 0.35,237 ["all", seen, ["!", activeState]],238 0.62,239 1,240 ] as ExpressionSpecification;241242 // v2 : bulles compactes et légères — halo diffus + pastille — au lieu des243 // gros disques opaques. La valeur indicative s'affiche SOUS la bulle.244 const clusterRadius: ExpressionSpecification = [245 "step",246 ["get", "point_count"],247 12,248 10,249 14.5,250 50,251 17,252 200,253 20,254 ];255256 const clusterRing: CircleLayerSpecification = {257 id: LAYER_IDS.clusterRing,258 slot: "top",259 type: "circle",260 source: sourceId,261 filter: ["has", "point_count"],262 paint: {263 "circle-color": theme.cluster.border,264 "circle-radius": ["+", clusterRadius, 5] as ExpressionSpecification,265 "circle-opacity": 0.22,266 "circle-blur": 0.45,267 },268 };269270 const clusterCircle: CircleLayerSpecification = {271 id: LAYER_IDS.clusters,272 slot: "top",273 type: "circle",274 source: sourceId,275 filter: ["has", "point_count"],276 paint: {277 "circle-color": theme.cluster.background,278 "circle-stroke-color": theme.cluster.border,279 "circle-stroke-width": 1.5,280 "circle-radius": clusterRadius,281 "circle-opacity": 0.96,282 },283 };284285 const clusterCount: SymbolLayerSpecification = {286 id: LAYER_IDS.clusterCount,287 slot: "top",288 type: "symbol",289 source: sourceId,290 filter: ["has", "point_count"],291 layout: {292 "text-field": ["get", "point_count_abbreviated"],293 "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],294 "text-size": ["step", ["get", "point_count"], 11.5, 50, 12.5],295 "text-allow-overlap": true,296 },297 paint: { "text-color": theme.cluster.text },298 };299300 // Valeur indicative (moyenne bornée) sous la bulle — texte halo, pas de301 // deuxième boîte : la carte respire.302 const clusterValue: SymbolLayerSpecification = {303 id: LAYER_IDS.clusterValue,304 slot: "top",305 type: "symbol",306 source: sourceId,307 filter: ["all", ["has", "point_count"], [">", ["to-number", ["get", "valueCount"]], 0]],308 minzoom: 9,309 layout: {310 "text-field": [311 "concat",312 "≈",313 compactPriceExpression([314 "/",315 ["to-number", ["get", "valueSum"]],316 ["max", 1, ["to-number", ["get", "valueCount"]]],317 ] as ExpressionSpecification),318 ],319 "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"],320 "text-size": 10,321 "text-anchor": "top",322 "text-offset": [0, 1.55],323 "text-allow-overlap": true,324 },325 paint: {326 "text-color": theme.cluster.valueText ?? theme.cluster.border,327 "text-halo-color": theme.cluster.valueHalo ?? "rgba(255,255,255,0.85)",328 "text-halo-width": 1.1,329 "text-opacity": 0.95,330 },331 };332333 const pointDot: CircleLayerSpecification = {334 id: LAYER_IDS.pointDot,335 slot: "top",336 type: "circle",337 source: sourceId,338 filter: ["!", ["has", "point_count"]],339 paint: {340 "circle-color": colors.background,341 "circle-radius": [342 "case",343 ["boolean", ["feature-state", "selected"], false],344 5,345 3.5,346 ],347 "circle-stroke-color": colors.halo,348 "circle-stroke-width": 1.5,349 "circle-opacity": stateOpacity,350 "circle-stroke-opacity": stateOpacity,351 },352 };353354 const pointPill: SymbolLayerSpecification = {355 id: LAYER_IDS.pointPill,356 slot: "top",357 type: "symbol",358 source: sourceId,359 filter: [360 "all",361 ["!", ["has", "point_count"]],362 ["!=", ["get", "labelValue"], null],363 ],364 layout: {365 "icon-image": pillIconExpression(null),366 "icon-text-fit": "both",367 "icon-text-fit-padding": [2.5, 8.5, 7.5, 8.5],368 "icon-anchor": "bottom",369 "icon-allow-overlap": false,370 "icon-optional": false,371 "text-field": compactPriceExpression(["get", "labelValue"] as ExpressionSpecification),372 "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],373 "text-size": ["interpolate", ["linear"], ["zoom"], 13, 10.5, 17, 12.5],374 "text-anchor": "bottom",375 "text-offset": [0, -0.9],376 "text-allow-overlap": false,377 "text-optional": false,378 // feature-state est interdit dans les propriétés layout : le tri se379 // fait par valeur (les plus chères gagnent les collisions de labels).380 "symbol-sort-key": [381 "-",382 10000000,383 ["to-number", ["coalesce", ["get", "labelValue"], 0]],384 ],385 },386 paint: {387 "icon-opacity": stateOpacity,388 "text-color": colors.text,389 "text-opacity": stateOpacity,390 },391 };392393 return [clusterRing, clusterCircle, clusterCount, clusterValue, pointDot, pointPill];394}395396/**397 * Cluster aggregation: running sum/count of labelValue → indicative mean.398 * `clamp` bounds each point's contribution so a single junk price (a house399 * listed at 2 M$ in a rental category…) cannot poison a whole bubble.400 */401export function buildClusterProperties(402 clamp?: [number, number],403): Record<string, unknown> {404 const raw: unknown = ["to-number", ["coalesce", ["get", "labelValue"], 0]];405 const contribution = clamp406 ? ["min", clamp[1], ["max", clamp[0], raw]]407 : raw;408 return {409 valueSum: ["+", ["case", ["!=", ["get", "labelValue"], null], contribution, 0]],410 valueCount: ["+", ["case", ["!=", ["get", "labelValue"], null], 1, 0]],411 // Fourchette de prix du cluster (tooltip au survol). Les valeurs nulles412 // reçoivent une sentinelle neutre pour chaque agrégat ; dès qu'une vraie413 // valeur existe dans la bulle, min/max sont exacts.414 valueMin: ["min", ["case", ["!=", ["get", "labelValue"], null], contribution, 99999999]],415 valueMax: ["max", ["case", ["!=", ["get", "labelValue"], null], contribution, 0]],416 };417}418419/** Default aggregation (no clamp) — kept for direct layer consumers. */420export const CLUSTER_PROPERTIES: Record<string, unknown> = buildClusterProperties();421