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 scouting interface is composed of five React components. MapViewer owns the MapLibre GL JS instance and all map interaction. ToolPanel is the 320 px sidebar that hosts the seven ScoreSlider controls and the Tooltip detail panel. DataStatus renders a freshness badge in the header. Together they communicate through props and callbacks flowing down from ScoutingPage — no global state library is used.

MapViewer

File: src/components/MapViewer.jsx Initialises the MapLibre GL JS map on mount, adds the GeoJSON source and two rendering layers, and wires up click and hover event handlers.

Props

PropTypeDescription
mapRefReact.MutableRefObjectRef that receives the maplibregl.Map instance — shared with useMapStyle
onFeatureClick(properties: object) => voidCalled with the raw GeoJSON feature properties when the user clicks a census section

Map Initialisation

new maplibregl.Map({
  container: mapContainerRef.current,
  center:  [-7.8, 42.8],   // Geographic centre of Galicia
  zoom:    7,               // Full-region view on load
  minZoom: 6,
  maxZoom: 16,
  style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
});
A NavigationControl (zoom + compass) is added to 'top-left'.

Layers

Both layers are added inside map.on('load') after the GeoJSON is fetched and added as a source.
Layer IDTypePurpose
secciones-fillfillHeat-map colour layer — paint property updated by useMapStyle
secciones-outlinelineSubtle white section borders — opacity ramps from invisible at zoom 8 to visible at zoom 10+
Both layers use source: sourceName (default 'galicia-scouting') and the GeoJSON feature collection loaded at mount time. When using vector tiles the equivalent field would be source-layer: 'secciones'.

Events

EventTriggerEffect
click on secciones-fillUser clicks a census sectionCalls onFeatureClick(feature.properties) and opens a floating maplibregl.Popup with municipality name, cusec code, renta, and actividad
mouseenter on secciones-fillCursor enters a sectionSets canvas.style.cursor = 'pointer'
mouseleave on secciones-fillCursor leaves a sectionResets cursor to ''

ToolPanel

File: src/components/ToolPanel.jsx The 320 px fixed left sidebar. It receives the weight state and change handler from ScoutingPage and distributes them to the child components.

Props

PropTypeDescription
variablesArray<VariableDescriptor>The VARIABLES array from constants/variables.js
weightsobjectCurrent weight values keyed by variable.key
onWeightChange(key: string, value: number) => voidPassed to each ScoreSlider as onChange
featureobject | nullGeoJSON properties of the clicked feature, or null
onClearFeature() => voidPassed to Tooltip as onClose

Structure

The sidebar is rendered as an <aside className="sidebar"> containing three <section className="sidebar-section"> blocks:
┌─────────────────────────────┐
│  SCOUTING SCORE             │  ← h3, introductory paragraph
│  Ajusta qué importa…        │
├─────────────────────────────┤
│  VARIABLES                  │  ← h3
│  [ScoreSlider × 7]          │  ← one per entry in VARIABLES
├─────────────────────────────┤
│  INFORMACIÓN DE ZONA        │  ← h3
│  [<Tooltip />]              │  ← populated after a map click
└─────────────────────────────┘

ScoreSlider

File: src/components/ScoreSlider.jsx A single labelled range input representing the importance weight for one scouting indicator.

Props

PropTypeDescription
variableVariableDescriptorDescriptor object with key, label, description, enabled, absKey, absLabel, absFormat
valuenumber (0–1)Current slider position — controlled by ScoutingPage state
onChange(key: string, value: number) => voidFires on every input event with the variable key and the new numeric value

Rendering

const isEnabled = variable.enabled !== false;

<div className={`slider-card ${isEnabled ? '' : 'disabled'}`}>
  <label>
    <span>{variable.label}</span>
    <span>{Math.round(value * 100)}%</span>   {/* live percentage readout */}
  </label>
  <input
    type="range"
    min="0" max="1" step="0.01"
    value={value}
    disabled={!isEnabled}
    onChange={(event) => onChange(variable.key, Number(event.target.value))}
  />
  <small>{variable.description}</small>
</div>

Visual States

StateAppearance
value === 0Label text in --color-gray-500; track rendered in gray
value > 0Label text in --color-gray-900; thumb and filled track in --color-secondary (green accent)
variable.enabled === falseEntire card receives disabled CSS class; <input> has the disabled attribute; cursor is not-allowed

DataStatus

File: src/components/DataStatus.jsx A lightweight status badge rendered in the application header that communicates whether the underlying scouting dataset is up to date.

Behaviour

On mount, DataStatus calls getDataStatus() from services/api.js, which issues GET /api/status (configurable via REACT_APP_DATA_STATUS_URL). The endpoint is expected to return a JSON object containing an updated_at ISO-8601 timestamp.
// services/api.js
export async function getDataStatus() {
  const url = import.meta.env.REACT_APP_DATA_STATUS_URL || '/api/status';
  const response = await client.get(url, { responseType: 'json' });
  return response.data;
}
The component computes ageInDays = (now - updatedAt) / 86_400_000 and chooses one of two badge variants:
ConditionBadge
ageInDays ≤ 400✅ Datos: DD/MM/YYYY — normal style
ageInDays > 400⚠️ Datos: DD/MM/YYYY — warning style (yellow)
If the fetch fails for any reason (network error, 404, CORS, timeout), the component sets hasError = true and renders nullthe badge disappears silently without blocking the rest of the application.

Tooltip

File: src/components/Tooltip.jsx Displays the full metric breakdown for a clicked census section. Rendered inside the sidebar rather than as a floating map popup, which keeps the dark map canvas unobstructed.

Props

PropTypeDescription
featureobject | nullRaw GeoJSON feature properties from the click event, or null when nothing is selected
onClose() => voidCalled when the user clicks the close button — clears the feature in ScoutingPage state
variablesArray<VariableDescriptor>The VARIABLES array, used to render each metric row via absLabel and absFormat

Idle State

When feature is null, the component renders a neutral placeholder:
┌────────────────────────────────┐
│  Información de zona           │
│  Haz clic en una sección del   │
│  mapa para ver sus métricas.   │
└────────────────────────────────┘

Active State

When a feature is selected, the panel shows:
┌────────────────────────────────┐
│  A Coruña                [Cerrar]
│  Sección: 1500101001           │
│  ─────────────────────────── │
│  Renta media       38.500 €    │
│  Habitantes         3.200 hab. │
│  % Jóvenes              0.34   │
│  % Mayores              0.51   │
│  Establecimientos   45 estab.  │
│  Índice comercial       0.62   │
│  Índice antigüedad      0.48   │
└────────────────────────────────┘
  • Titlefeature.NMUN (municipality name), falling back to 'Municipio'
  • Subtitlefeature.cusec (census section code)
  • Close button — calls onClose; styled as map-floating-btn
  • Metric rows — one row per entry in variables, rendered with variable.absLabel and variable.absFormat(feature[variable.absKey])

Quick Map Popup

In addition to the sidebar panel, MapViewer creates a maplibregl.Popup anchored to the click coordinates with a brief summary (municipality name, cusec code, renta, and actividad). This popup closes automatically when the user clicks a different section.

VARIABLES Array — Full Reference

Defined in src/constants/variables.js and passed as a prop throughout the component tree:
export const VARIABLES = [
  {
    key:         'renta_norm',
    label:       'Renta',
    description: 'Capacidad adquisitiva media de la sección censal.',
    enabled:     true,
    absKey:      'renta_abs',
    absLabel:    'Renta media',
    absFormat:   (v) => `${Number(v || 0).toLocaleString('es-ES')} €`,
  },
  {
    key:         'densidad_norm',
    label:       'Densidad',
    description: 'Sin datos disponibles.',
    enabled:     false,                          // slider rendered disabled
    absKey:      'poblacion_abs',
    absLabel:    'Habitantes',
    absFormat:   (v) => `${Number(v || 0).toLocaleString('es-ES')} hab.`,
  },
  {
    key:         'jovenes_norm',
    label:       'Jóvenes',
    description: 'Peso relativo de población menor de 20 años.',
    enabled:     true,
    absKey:      'jovenes_norm',
    absLabel:    '% Jóvenes',
    absFormat:   (v) => Number(v || 0).toFixed(2),
  },
  {
    key:         'mayores_norm',
    label:       'Mayores',
    description: 'Peso relativo de población mayor de 64 años.',
    enabled:     true,
    absKey:      'mayores_norm',
    absLabel:    '% Mayores',
    absFormat:   (v) => Number(v || 0).toFixed(2),
  },
  {
    key:         'actividad_norm',
    label:       'Actividad',
    description: 'Densidad de puntos de interés (comercios, restaurantes, oficinas).',
    enabled:     true,
    absKey:      'actividad_abs',
    absLabel:    'Establecimientos',
    absFormat:   (v) => `${Number(v || 0).toLocaleString('es-ES')} estab.`,
  },
  {
    key:         'uso_comercial_norm',
    label:       'Uso comercial',
    description: 'Ratio de edificios con uso comercial/industrial vs residencial.',
    enabled:     true,
    absKey:      'uso_comercial_norm',
    absLabel:    'Índice comercial',
    absFormat:   (v) => Number(v || 0).toFixed(2),
  },
  {
    key:         'antiguedad_norm',
    label:       'Antigüedad',
    description: 'Antigüedad media de los edificios (más nuevo = mayor valor).',
    enabled:     true,
    absKey:      'antiguedad_norm',
    absLabel:    'Índice antigüedad',
    absFormat:   (v) => Number(v || 0).toFixed(2),
  },
];

Build docs developers (and LLMs) love