TypeScript 87.7%
CSS 12.3%
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 // R < H/2 - 1 obligatoire : la bande stretchY est [(R+1)·DPR, (H-R-1)·DPR]78 // et Mapbox exige début < fin — R=12.5 sur H=26 inverse la bande (image79 // rejetée : « invalid stretchY value », pilules invisibles).80 const R = 11.5;81 const TAIL = 5;8283 // v2 « premium » : pilule plus fine, ombre diffuse légère, liseré hairline84 // (fini l'effet gros bouton opaque posé sur la carte).85 const draw = (background: string, border: string): ImageData => {86 const canvas = document.createElement("canvas");87 canvas.width = W * DPR;88 canvas.height = (H + TAIL + 4) * DPR;89 const ctx = canvas.getContext("2d");90 if (!ctx) throw new Error("Canvas 2D indisponible");91 ctx.scale(DPR, DPR);92 ctx.shadowColor = "rgba(12, 14, 18, 0.20)";93 ctx.shadowBlur = 7;94 ctx.shadowOffsetY = 2;95 // corps de la pastille96 ctx.beginPath();97 ctx.roundRect(1, 1, W - 2, H - 2, R);98 ctx.fillStyle = background;99 ctx.fill();100 // pointe vers la coordonnée101 ctx.shadowColor = "transparent";102 ctx.beginPath();103 ctx.moveTo(W / 2 - 4.5, H - 1.5);104 ctx.lineTo(W / 2, H + TAIL - 1);105 ctx.lineTo(W / 2 + 4.5, H - 1.5);106 ctx.closePath();107 ctx.fillStyle = background;108 ctx.fill();109 ctx.beginPath();110 ctx.roundRect(1, 1, W - 2, H - 2, R);111 ctx.strokeStyle = border;112 ctx.lineWidth = 1.1;113 ctx.stroke();114 return ctx.getImageData(0, 0, canvas.width, canvas.height);115 };116117 for (const family of PILL_FAMILIES) {118 const tokens = markerTokens(theme, family);119 const variants: [string, string, string][] = [120 [pillImageId(family), tokens.background, tokens.halo],121 [pillImageId(family, true), tokens.selectedBackground, tokens.halo],122 ];123 for (const [id, background, border] of variants) {124 if (map.hasImage(id)) continue;125 map.addImage(id, draw(background, border), {126 pixelRatio: DPR,127 stretchX: [[(R + 1) * DPR, (W - R - 1) * DPR]],128 stretchY: [[(R + 1) * DPR, (H - R - 1) * DPR]],129 content: [8 * DPR, 5 * DPR, (W - 8) * DPR, (H - 5) * DPR],130 });131 }132 }133}134135/** Family selector shared by icon and color expressions. */136function familyCase(byFamily: Record<PillFamily, string>): ExpressionSpecification {137 return [138 "case",139 ["==", ["get", "highlight"], 1],140 byFamily.highlight,141 ["==", ["get", "kind"], "valuation"],142 byFamily.valuation,143 ["==", ["get", "listingType"], "rent"],144 byFamily.rent,145 byFamily.sale,146 ] as ExpressionSpecification;147}148149/**150 * icon-image expression (layout ⇒ feature-state interdit) : la sélection est151 * réinjectée par KaMap via setLayoutProperty à chaque changement.152 */153export function pillIconExpression(154 selectedId: string | null,155): ExpressionSpecification {156 const base = familyCase({157 sale: pillImageId("sale"),158 rent: pillImageId("rent"),159 valuation: pillImageId("valuation"),160 highlight: pillImageId("highlight"),161 });162 if (selectedId === null) return base;163 const selected = familyCase({164 sale: pillImageId("sale", true),165 rent: pillImageId("rent", true),166 valuation: pillImageId("valuation", true),167 highlight: pillImageId("highlight", true),168 });169 return [170 "case",171 ["==", ["get", "id"], selectedId],172 selected,173 base,174 ] as ExpressionSpecification;175}176177interface FamilyColors {178 background: ExpressionSpecification;179 text: ExpressionSpecification;180 halo: ExpressionSpecification;181}182183/** Data-driven colors: family (sale/rent/valuation/highlight) × state. */184function familyColorExpressions(theme: KaMapTheme): FamilyColors {185 const sale = markerTokens(theme, "sale");186 const rent = markerTokens(theme, "rent");187 const valuation = markerTokens(theme, "valuation");188 const highlight = markerTokens(theme, "highlight");189190 const isHighlight: ExpressionSpecification = ["==", ["get", "highlight"], 1];191 const isValuation: ExpressionSpecification = ["==", ["get", "kind"], "valuation"];192 const isRent: ExpressionSpecification = ["==", ["get", "listingType"], "rent"];193 const selected: ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];194 const hovered: ExpressionSpecification = ["boolean", ["feature-state", "hovered"], false];195 const active: ExpressionSpecification = ["any", selected, hovered];196197 const pick = (key: keyof typeof sale): ExpressionSpecification =>198 [199 "case",200 isHighlight,201 highlight[key],202 isValuation,203 valuation[key],204 isRent,205 rent[key],206 sale[key],207 ] as ExpressionSpecification;208209 return {210 background: [211 "case",212 active,213 pick("selectedBackground"),214 pick("background"),215 ] as ExpressionSpecification,216 text: ["case", active, pick("selectedText"), pick("text")] as ExpressionSpecification,217 halo: pick("halo"),218 };219}220221/** All property/cluster layer specifications for the given theme. */222export function buildPropertyLayers(223 theme: KaMapTheme,224 sourceId: string = PROPERTY_SOURCE_ID,225 opts?: {226 /** N minimal d'items pour afficher la valeur sous la bulle — cartes227 * denses (Immo-Ka) : éviter un ≈prix sous chaque micro-bulle. */228 valueMinCount?: number;229 },230): (SymbolLayerSpecification | CircleLayerSpecification)[] {231 const colors = familyColorExpressions(theme);232 const dimmed: ExpressionSpecification = ["boolean", ["feature-state", "dimmed"], false];233 // « Vu » : annonce déjà consultée — atténuée mais lisible ; la sélection234 // et le survol reprennent toujours la pleine opacité.235 const seen: ExpressionSpecification = ["boolean", ["feature-state", "seen"], false];236 const activeState: ExpressionSpecification = [237 "any",238 ["boolean", ["feature-state", "selected"], false],239 ["boolean", ["feature-state", "hovered"], false],240 ];241 const stateOpacity: ExpressionSpecification = [242 "case",243 dimmed,244 0.35,245 ["all", seen, ["!", activeState]],246 0.62,247 1,248 ] as ExpressionSpecification;249250 // v2 : bulles compactes et légères — halo diffus + pastille — au lieu des251 // gros disques opaques. La valeur indicative s'affiche SOUS la bulle.252 const clusterRadius: ExpressionSpecification = [253 "step",254 ["get", "point_count"],255 12,256 10,257 14.5,258 50,259 17,260 200,261 20,262 ];263264 const clusterRing: CircleLayerSpecification = {265 id: LAYER_IDS.clusterRing,266 slot: "top",267 type: "circle",268 source: sourceId,269 filter: ["has", "point_count"],270 paint: {271 "circle-color": theme.cluster.border,272 "circle-radius": ["+", clusterRadius, 5] as ExpressionSpecification,273 "circle-opacity": 0.22,274 "circle-blur": 0.45,275 },276 };277278 const clusterCircle: CircleLayerSpecification = {279 id: LAYER_IDS.clusters,280 slot: "top",281 type: "circle",282 source: sourceId,283 filter: ["has", "point_count"],284 paint: {285 "circle-color": theme.cluster.background,286 "circle-stroke-color": theme.cluster.border,287 "circle-stroke-width": 1.5,288 "circle-radius": clusterRadius,289 "circle-opacity": 0.96,290 },291 };292293 const clusterCount: SymbolLayerSpecification = {294 id: LAYER_IDS.clusterCount,295 slot: "top",296 type: "symbol",297 source: sourceId,298 filter: ["has", "point_count"],299 layout: {300 "text-field": ["get", "point_count_abbreviated"],301 "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],302 "text-size": ["step", ["get", "point_count"], 11.5, 50, 12.5],303 "text-allow-overlap": true,304 },305 paint: { "text-color": theme.cluster.text },306 };307308 // Valeur indicative (moyenne bornée) sous la bulle — texte halo, pas de309 // deuxième boîte : la carte respire.310 const clusterValue: SymbolLayerSpecification = {311 id: LAYER_IDS.clusterValue,312 slot: "top",313 type: "symbol",314 source: sourceId,315 filter: [316 "all",317 ["has", "point_count"],318 [">", ["to-number", ["get", "valueCount"]], 0],319 [">=", ["to-number", ["get", "point_count"]], opts?.valueMinCount ?? 0],320 ],321 minzoom: 9,322 layout: {323 "text-field": [324 "concat",325 "≈",326 compactPriceExpression([327 "/",328 ["to-number", ["get", "valueSum"]],329 ["max", 1, ["to-number", ["get", "valueCount"]]],330 ] as ExpressionSpecification),331 ],332 "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"],333 "text-size": 10,334 "text-anchor": "top",335 "text-offset": [0, 1.55],336 "text-allow-overlap": true,337 },338 paint: {339 "text-color": theme.cluster.valueText ?? theme.cluster.border,340 "text-halo-color": theme.cluster.valueHalo ?? "rgba(255,255,255,0.85)",341 "text-halo-width": 1.1,342 "text-opacity": 0.95,343 },344 };345346 const pointDot: CircleLayerSpecification = {347 id: LAYER_IDS.pointDot,348 slot: "top",349 type: "circle",350 source: sourceId,351 filter: ["!", ["has", "point_count"]],352 paint: {353 "circle-color": colors.background,354 "circle-radius": [355 "case",356 ["boolean", ["feature-state", "selected"], false],357 5,358 3.5,359 ],360 "circle-stroke-color": colors.halo,361 "circle-stroke-width": 1.5,362 "circle-opacity": stateOpacity,363 "circle-stroke-opacity": stateOpacity,364 },365 };366367 const pointPill: SymbolLayerSpecification = {368 id: LAYER_IDS.pointPill,369 slot: "top",370 type: "symbol",371 source: sourceId,372 filter: [373 "all",374 ["!", ["has", "point_count"]],375 ["!=", ["get", "labelValue"], null],376 ],377 layout: {378 "icon-image": pillIconExpression(null),379 "icon-text-fit": "both",380 "icon-text-fit-padding": [2.5, 8.5, 7.5, 8.5],381 "icon-anchor": "bottom",382 "icon-allow-overlap": false,383 "icon-optional": false,384 "text-field": compactPriceExpression(["get", "labelValue"] as ExpressionSpecification),385 "text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],386 "text-size": ["interpolate", ["linear"], ["zoom"], 13, 10.5, 17, 12.5],387 "text-anchor": "bottom",388 "text-offset": [0, -0.9],389 "text-allow-overlap": false,390 "text-optional": false,391 // feature-state est interdit dans les propriétés layout : le tri se392 // fait par valeur (les plus chères gagnent les collisions de labels).393 "symbol-sort-key": [394 "-",395 10000000,396 ["to-number", ["coalesce", ["get", "labelValue"], 0]],397 ],398 },399 paint: {400 "icon-opacity": stateOpacity,401 "text-color": colors.text,402 "text-opacity": stateOpacity,403 },404 };405406 // Les points nus passent SOUS les bulles de clusters (une pastille claire407 // ne doit jamais percer le texte d'une bulle voisine) ; les pilules de408 // prix restent au sommet.409 return [pointDot, clusterRing, clusterCircle, clusterCount, clusterValue, pointPill];410}411412/**413 * Cluster aggregation: running sum/count of labelValue → indicative mean.414 * `clamp` bounds each point's contribution so a single junk price (a house415 * listed at 2 M$ in a rental category…) cannot poison a whole bubble.416 */417export function buildClusterProperties(418 clamp?: [number, number],419): Record<string, unknown> {420 const raw: unknown = ["to-number", ["coalesce", ["get", "labelValue"], 0]];421 const contribution = clamp422 ? ["min", clamp[1], ["max", clamp[0], raw]]423 : raw;424 return {425 valueSum: ["+", ["case", ["!=", ["get", "labelValue"], null], contribution, 0]],426 valueCount: ["+", ["case", ["!=", ["get", "labelValue"], null], 1, 0]],427 // Fourchette de prix du cluster (tooltip au survol). Les valeurs nulles428 // reçoivent une sentinelle neutre pour chaque agrégat ; dès qu'une vraie429 // valeur existe dans la bulle, min/max sont exacts.430 valueMin: ["min", ["case", ["!=", ["get", "labelValue"], null], contribution, 99999999]],431 valueMax: ["max", ["case", ["!=", ["get", "labelValue"], null], contribution, 0]],432 };433}434435/** Default aggregation (no clamp) — kept for direct layer consumers. */436export const CLUSTER_PROPERTIES: Record<string, unknown> = buildClusterProperties();437