SPB Git

spb/ka-maps

Public
13.7 KB · 421 lines typescript
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
Files · 30fa6e4cf899c7ac456a9801f72df3ea1395b625
Menu

Theme