SPB Git forge

spb/ka-maps

Public
6commits 1branches 0releases
448.0 KBsize
maindefault branch
29 days agolast push
TypeScript 87.7% CSS 12.3%
14.4 KB · 437 lines typescript
Raw Blame History
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