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.

UrbanViable’s map coloring is driven entirely by a weighted sum computed on the GPU for each of Galicia’s ~3,800 census sections. The formula combines up to seven pre-normalized variables, each multiplied by a user-controlled weight, and divides by the sum of active weights to keep the result in a fixed [0, 1] range. The score is never transmitted over the network — it is computed in real time by MapLibre GL JS’s paint expression engine from properties already embedded in the vector tiles.

Core Formula

score = Σ(variable_norm_i × weight_i) / Σ(weight_j  for all active j)
Why divide by the sum of active weights? Without the division, the score would contract whenever the user deactivates variables. For example, if two variables both have weight 0.5 and one is turned off, the remaining variable would only contribute up to 0.5 — and the entire color ramp would compress into the bottom half of the gradient. Dividing by the sum of active weights re-normalizes the score to the full [0, 1] range regardless of how many sliders are active or what their individual values are. A single slider at any non-zero value produces a map that uses the full color ramp from gray to deep red.

Min-Max Normalization

All variables except commercial activity use standard Min-Max normalization:
variable_norm = (x - min) / (max - min)
The Python implementation used in the ETL:
def minmax_norm(series: pd.Series) -> pd.Series:
    """
    Min-Max normalize a pandas Series to [0, 1].
    NaN values are filled with 0 before normalization.
    Result is rounded to 4 decimal places.
    """
    values = series.fillna(0)
    vmin = values.min()
    vmax = values.max()
    if vmax == vmin:
        return pd.Series(0.0, index=values.index)
    return ((values - vmin) / (vmax - vmin)).round(4)
Galicia-wide normalization. The min and max are computed over all ~3,800 sections in Galicia — not over the currently visible viewport and not clipped to a single province. This means a section’s normalized value is stable regardless of zoom level or map position: a section that scores 0.72 on renta_norm will always score 0.72 whether the user is zoomed to Santiago de Compostela or viewing all of Galicia at once. NaN treatment. Source datasets do not cover 100% of sections. The INE suppresses income data for sections with fewer than 100 households. IGE may lack population figures for very small sections. Before normalization, all NaN values are filled with 0. This means data-absent sections are treated as the minimum value — they appear at the cold end of the color ramp for that variable, which is conservative and visually honest.

Logarithmic Normalization for Activity

The actividad_norm column uses a two-step normalization:
actividad_norm = minmax_norm(log(1 + density_activity))
where density_activity = actividad_abs / area_km². The Python implementation:
def log_norm(series):
    """
    Log-transform then Min-Max normalize.
    Applies log(1 + x) to handle zero values before normalizing.
    """
    values = series.fillna(0).clip(lower=0)
    log_vals = np.log1p(values)   # log(1 + x), defined at x = 0
    return minmax_norm(log_vals)

gdf['actividad_norm'] = log_norm(gdf['densidad_actividad'])
Why log normalization? Commercial activity follows a strongly skewed distribution: the overwhelming majority of Galician census sections have very few establishments (rural areas, residential neighborhoods), while a small number of sections in city centers — Vigo’s old town, A Coruña’s port district — have hundreds. Without log compression, Min-Max normalization would place nearly every section near zero and concentrate all color variation at the very top of the distribution. The log transform compresses the high end and stretches the low end, producing a map where meaningful gradations are visible across the full range of Galician territory.

Weight Semantics

Weights are dimensionless values in [0, 1] controlled by the sidebar sliders. They represent the relative importance the user assigns to each variable when scouting a location. Default weights (from src/constants/variables.js):
export const DEFAULT_WEIGHTS = {
  renta_norm:         0.2,   // purchasing power
  densidad_norm:      0.0,   // population density (disabled by default)
  jovenes_norm:       0.2,   // % population under 20
  mayores_norm:       0.2,   // % population over 64
  actividad_norm:     0.2,   // commercial activity density
  uso_comercial_norm: 0.1,   // ratio of commercial/office buildings
  antiguedad_norm:    0.1,   // building modernity
};
Key semantics:
  • A weight of 0 excludes the variable entirely from the score. The variable’s _norm column is ignored in the sum and its weight does not count toward the denominator. Setting densidad_norm: 0.0 means density has no influence on the map, as in the default configuration.
  • All weights at 0totalWeight === 0 → the formula is undefined. useMapStyle handles this by applying a flat neutral color (rgba(80, 80, 100, 0.2)) rather than attempting a division by zero. The map appears uniformly gray.
  • Slider values are not automatically normalized to sum to 1. The user can set all sliders to 1.0 or any arbitrary combination. The division in the formula absorbs whatever the total weight happens to be.

MapLibre GL JS Expression

MapLibre paint expressions are JSON arrays evaluated by the GPU. They have no access to the JavaScript scope — weights must be embedded as numeric literals at the time setPaintProperty is called. The useMapStyle hook in src/hooks/useMapStyle.js constructs the expression dynamically from the current weights object:
// Filter to variables with non-zero weight
const activeVars = vars.filter(v => (weights[v] || 0) > 0);

// Build the weighted sum expression bottom-up
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;
}

// Divide by total weight to normalize to [0, 1]
if (totalWeight > 0 && activeVars.length > 1) {
  scoreExpr = ['/', scoreExpr, totalWeight];
}
The resulting scoreExpr is then passed to the color interpolation ramp:
const fillColorExpression = [
  'interpolate',
  ['linear'],
  scoreExpr,
  0,    'rgba(30,  30,  50,  0.60)',   // no score    → near-invisible dark
  0.25, 'rgba(50,  120, 220, 0.80)',   // low score   → blue
  0.50, 'rgba(255, 200, 0,   0.90)',   // mid score   → yellow-orange
  0.75, 'rgba(255, 100, 0,   0.95)',   // high score  → orange-red
  1,    'rgba(220, 20,  60,  1.00)'    // peak score  → deep red
];

map.setPaintProperty('secciones-fill', 'fill-color', fillColorExpression);
Why numeric literals matter. When setPaintProperty is called, MapLibre serializes the expression array to JSON and sends it to the WebGL shader. At that point, there is no JavaScript runtime — weights.renta_norm would serialize as undefined. The hook therefore reads each weight value from the weights object before building the array, embedding the resolved number directly. This is why the expression must be rebuilt from scratch on every slider change rather than referencing a live JS variable.

Data Quality and Scoring Behavior

Sections that are missing data for a variable receive 0.0 for that _norm column (per the NaN fill described in the normalization section above). This has a predictable and documented effect on scoring:
  • A section with actividad_norm: 0.0 because OSM data was unavailable will score the same as a section with genuinely zero commercial activity.
  • If actividad_norm has a non-zero weight, these sections will appear cooler (bluer or grayer) on the map, which may understate their actual commercial potential.
For this reason, the post-ETL quality assertions print a diagnostic showing what percentage of sections are at 0 for each variable, and warn if that percentage exceeds 20% for any column.

Build docs developers (and LLMs) love