Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/danizd/urbanviable/llms.txt

Use this file to discover all available pages before exploring further.

When a user drags a slider, UrbanViable does not call an API, re-fetch tiles, or run a JavaScript loop over features. Instead, the useMapStyle hook assembles a MapLibre GL JS paint expression — a JSON array of operators — and pushes it to the GPU in a single setPaintProperty call. The GPU then evaluates that expression independently for every visible census section on each frame, producing the colour ramp at up to 60 fps. MapLibre GL JS expressions are pure JSON arrays evaluated in the WebGL shader pipeline. They have no access to JavaScript scope, closures, or React state. Weight values cannot be referenced by name — they must be embedded as numeric literals inside the expression array at the moment it is constructed. This is why useMapStyle rebuilds the entire expression object on every weight change rather than passing variable references.

Scoring Formula

score = Σ(variable_norm × weight_i) / Σ(active_weights)
Dividing by the sum of active weights normalises the result so that score is always in [0, 1], regardless of how many sliders are active or what values they hold. A user who enables only one slider at 0.3 gets the same colour scale as a user who enables all seven — the relative emphasis of each active indicator is preserved.

useMapStyle Hook — Full Source

import { useEffect, useRef } from 'react';

export function useMapStyle(mapRef, weights) {
  const layerCheckInterval = useRef(null);
  const timeoutRef = useRef(null);

  useEffect(() => {
    const map = mapRef.current;
    if (!map || typeof map.setPaintProperty !== 'function') {
      return;
    }

    const applyExpression = (retry = true) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }

      // Debounce: wait 100 ms after the last weight change before updating the GPU
      timeoutRef.current = setTimeout(() => {
        const vars = [
          'renta_norm',
          'densidad_norm',
          'jovenes_norm',
          'mayores_norm',
          'actividad_norm',
          'uso_comercial_norm',
          'antiguedad_norm',
        ];

        // 1. Filter to variables whose weight is greater than zero
        const activeVars = vars.filter(v => (weights[v] || 0) > 0);

        // 2. If no sliders are active, fall back to flat gray
        if (activeVars.length === 0) {
          if (map.getLayer('secciones-fill')) {
            map.setPaintProperty('secciones-fill', 'fill-color', 'rgba(80, 80, 100, 0.2)');
            map.setPaintProperty('secciones-fill', 'fill-opacity', 0.9);
          }
          return;
        }

        // 3. Build the weighted sum expression iteratively
        //    ['*', ['get', 'renta_norm'], 0.25]  ← weight is a numeric literal
        let scoreExpr = ['*', ['get', activeVars[0]], weights[activeVars[0]]];
        let totalWeight = weights[activeVars[0]];

        for (let i = 1; i < activeVars.length; i++) {
          const v = activeVars[i];
          const w = weights[v] || 0;
          scoreExpr = ['+', scoreExpr, ['*', ['get', v], w]];
          totalWeight += w;
        }

        // 4. Divide by total weight so the result is always in [0, 1]
        if (totalWeight > 0 && activeVars.length > 1) {
          scoreExpr = ['/', scoreExpr, totalWeight];
        }

        // 5. Map the [0, 1] score to the colour ramp via linear interpolation
        const fillColorExpression = [
          'interpolate',
          ['linear'],
          scoreExpr,
          0,    'rgba(30, 30, 50, 0.6)',
          0.25, 'rgba(50, 120, 220, 0.8)',
          0.5,  'rgba(255, 200, 0, 0.9)',
          0.75, 'rgba(255, 100, 0, 0.95)',
          1,    'rgba(220, 20, 60, 1)',
        ];

        if (map.getLayer('secciones-fill')) {
          map.setPaintProperty('secciones-fill', 'fill-color', fillColorExpression);
          map.setPaintProperty('secciones-fill', 'fill-opacity', 0.9);
        } else if (retry) {
          // Layer not ready yet — try once more after 100 ms
          setTimeout(() => applyExpression(false), 100);
        }
      }, 100);
    };

    // Wait for the layer to exist before attempting setPaintProperty
    const waitForLayerAndApply = () => {
      if (map.getLayer('secciones-fill')) {
        applyExpression();
      } else {
        layerCheckInterval.current = setInterval(() => {
          if (map.getLayer('secciones-fill')) {
            applyExpression();
            if (layerCheckInterval.current) {
              clearInterval(layerCheckInterval.current);
              layerCheckInterval.current = null;
            }
          }
        }, 100);
      }
    };

    if (typeof map.isStyleLoaded === 'function' && map.isStyleLoaded()) {
      waitForLayerAndApply();
      return;
    }

    const onIdle = () => { waitForLayerAndApply(); };
    map.once('idle', onIdle);

    return () => {
      map.off('idle', onIdle);
      if (layerCheckInterval.current) clearInterval(layerCheckInterval.current);
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, [mapRef, weights]);
}

Expression Anatomy

The hook builds the expression in five steps:
StepWhat happens
1 — Filter active variablesvars.filter(v => weights[v] > 0) discards any slider set to 0 so it contributes nothing to the sum
2 — Zero-weight fallbackIf no variables are active totalWeight === 0, the layer is painted a flat rgba(80, 80, 100, 0.2) — uniform gray
3 — Build weighted sumStarting from the first active variable, each term is ['*', ['get', key], numericLiteral]; terms are chained with ['+', ...]
4 — NormaliseThe whole sum is wrapped in ['/', scoreExpr, totalWeight] when more than one variable is active, keeping the output in [0, 1]
5 — Colour interpolationAn interpolate / linear expression maps the score to the colour ramp stops below

Colour Ramp

Score stopColourMeaning
0rgba(30, 30, 50, 0.6)No score — near-invisible dark blue-gray
0.25rgba(50, 120, 220, 0.8)Low score — blue
0.5rgba(255, 200, 0, 0.9)Medium score — yellow-orange
0.75rgba(255, 100, 0, 0.95)High score — deep orange
1.0rgba(220, 20, 60, 1.0)Maximum score — red

Applying the Expression

map.setPaintProperty('secciones-fill', 'fill-color', fillColorExpression);
setPaintProperty replaces the GPU-side paint rule for the secciones-fill layer atomically. MapLibre recompiles the affected shader and re-renders all tiles on the next frame — the entire Galicia heatmap updates in under 16 ms on modern hardware without any data movement between CPU and GPU.

DEFAULT_WEIGHTS

constants/variables.js exports the initial slider positions loaded into React state on mount:
export const DEFAULT_WEIGHTS = {
  renta_norm:        0.2,
  densidad_norm:     0.0,   // disabled — no data available
  jovenes_norm:      0.2,
  mayores_norm:      0.2,
  actividad_norm:    0.2,
  uso_comercial_norm: 0.1,
  antiguedad_norm:   0.1,
};
densidad_norm starts at 0.0 because population-density data is not yet available for all sections; its ScoreSlider is rendered in a disabled state.

VARIABLES Array Structure

Each entry in VARIABLES describes one indicator. Here is a representative entry:
{
  key:         'actividad_norm',       // normalised property name in the GeoJSON/tile feature
  label:       'Actividad',            // displayed in the slider label
  description: 'Densidad de puntos de interés (comercios, restaurantes, oficinas).',
  enabled:     true,                   // false → slider rendered disabled
  absKey:      'actividad_abs',        // absolute-value property shown in the Tooltip
  absLabel:    'Establecimientos',     // label for the absolute value
  absFormat:   (v) => `${Number(v || 0).toLocaleString('es-ES')} estab.`,
}
The key field is the string passed to ['get', key] inside the MapLibre expression — it must exactly match the property name baked into the GeoJSON features.
If all slider weights are 0 (totalWeight === 0), the hook skips the interpolate expression entirely and calls map.setPaintProperty('secciones-fill', 'fill-color', 'rgba(80, 80, 100, 0.2)'). Every census section will appear as a uniform flat gray. This is intentional — an equal flat color indicates that no scoring criteria have been selected yet.
The layer IDs 'secciones-fill' and 'secciones-outline' in MapViewer.jsx must match the layer IDs passed to map.getLayer() and map.setPaintProperty() in useMapStyle. The current implementation uses a GeoJSON source — there is no source-layer field. If you migrate to vector tiles generated by Tippecanoe, add a source-layer field to each layer definition and ensure its value matches the --layer argument used when building the MBTiles file.

Build docs developers (and LLMs) love