Before making any file edits or starting implementation, FIRST provide a concise summary of findings/plan to the user and wait for confirmation.
When performing code reviews, ALWAYS use the svelte-code-writer skill for any Svelte (.svelte) file validation.
Visualization of prison isolation sanction reasons in Spain (2020-2025). Shows disciplinary measures broken down by reason code (motivo), classified as violent vs non-violent, across different prison administrations (AGE, CAT). Built with Svelte 5 (runes) + Vite, minimal D3 usage.
Live URL: https://graphs.civio.es/justicia/aislamiento-prisiones/motivos-sanciones/
Data source: https://data.civio.es/justicia/aislamiento-prisiones/motivos_aislamiento.csv
# IMPORTANT: Always run this first for correct Node.js version (v24.13.0)
source ~/.nvm/nvm.sh && nvm use
npm run dev # Vite dev server (localhost:5173)
npm run build # Production build to dist/
npm run preview # Preview production build
npm run lint # Oxlint on src/
npm run lint:fix # Auto-fix lint issues
npm run lint:deps # Unused dependency check (Knip)
npm run lint:all # Full lint + dependency check
npm run format # Prettier format
npm run format:check # Check formatting
npm run iframe # Generate iframes (uses Playwright)src/main.jsmounts App, readslang,data-a11yanddata-altfrom target<div id="motivos-sanciones">, passeschartIDApp.svelteloads CSV viadata.loadFromUrl()inonMountdata.svelte.jsprocesses CSV: filters by admin/gender, groups by motivoSquarePacking.svelterenders the main visualization
data.svelte.js— Core state class using$stateand$derived. Filters:selectedAdm(AGE/CAT),selectedSex(V/M/''),selectedYear. Chain:value→filteredRows(by adm) →rows(by sex) →concentricHierarchy.baseConcentricHierarchyuses max % across all filter combos for stable layout sizing.language.svelte.js— Spanish-only i18n. Contains the full motivo dictionary (18 sanction types, codes 108a–108i + 109a–109i) with labels, descriptions, type (grave/muy grave), and violence flag.utils.svelte.js— URL params (?lang=,?a11y,?alt), mobile detection via Svelte 5MediaQueryandSvelteURLfromsvelte/reactivity.
SquarePacking.svelte— Main viz. Square packing with concentric year rings per motivo. Three label modes (inline/displaced/code) with mobile-specific logic. See tunables section below.Tooltip.svelte— Year breakdown on hover/click withMiniChart. Desktop: positioned popover. Mobile: centered modal. UsesclickOutsideattachment.MiniChart.svelte— Small line chart inside tooltips showing % evolution per motivo across years.Selector.svelte— Animated radio selector with sliding indicator for admin/sex filters.ScreenReaderDescription.svelte— Accessible chart descriptions (generic, reusable).footer/Footer.svelte— Source link, methodology, share button.footer/ShareContainer.svelte— Embed code copy with clipboard API + fallback.
squarePacking.js—packSquaresBestFit()greedy packing algorithm (larger-first, closest to center).colors.js— Civio brand colors + per-administration colors (admColors). Generates CSS vars:--age,--cat,--primary,--bw0–--bw990.locale.js— Number formatters (formatIntegers,formatDecimals) for ES/EN locales.clickOutside.svelte.js— Svelte attachment for click-outside detection.
mainChart.js—buildMainChartA11y()builds screen reader descriptions and data tables for the main chart. Receives hierarchy, causes dictionary, and filter labels; returns{ description, title, columns, items }forScreenReaderDescription.
CSV fields: motivo, year, adm, tipo_sancion, sexo (M=mujeres/V=hombres), total.
Each motivo (e.g., "108a", "109b") maps to a full description in language.svelte.js with violence classification.
Years are strings ('2020'–'2025') from CSV parsing.
class Data {
value = $state(undefined);
selectedAdm = $state('AGE');
rows = $derived(/* filter logic */);
}
export const data = new Data();CSS variables injected on chart container via @html <style>: --civio-blue, --primary, --secondary, --bw0–--bw990, plus per-admin colors (--age, --cat). No fallback values needed — always injected.
Only csv() for loading, rollup() for aggregation, range() + scaleLinear() for BW scale and MiniChart, formatLocale() for number formatting in locale.js. No D3 axes or SVG generation — layout is custom JS, rendering is Svelte SVG.
- camelCase for all JS constants and variables (no UPPER_CASE)
- CSS: No fallback values for
--bw*or project vars (always available via injected<style>)
Oxlint reports prefer-const warnings on $state/$derived/$props — these are false positives (Svelte 5 runes require let). Ignore them.
| Constant | Value | Purpose |
|---|---|---|
inactiveOpacity |
0.05 | Label text when motivo has 0 in highlighted year |
baseLabelOpacity |
1 | Label text (inline/code) on desktop |
baseLabelOpacityMobile |
0.8 | Label text (inline/code) on mobile |
displacedLabelOpacity |
0.8 | Displaced (external) labels, both desktop & mobile |
rectFillOpacity |
0.85 | Solid rect fill (no year highlighted) |
rectInactiveOpacity |
0.08 | Rect fill for inactive motivos |
| Constant | Value | Purpose |
|---|---|---|
mobileInlineSide |
120 | Threshold: side >= this -> inline label; < this -> displaced/code |
displacedFontSize |
11 | Font size for displaced labels (x fontScale) |
displacedStatsInsideMin |
20 | Threshold: side >= this -> % inside square; < this -> % appended to external label |
displacedMaxChars |
20 | Max characters per line for displaced labels |
displacedGap |
5 | Gap between square edge and displaced label |
targetHeight |
400 | Target height for packing algorithm scaling |
Desktop:
- displaced — Has
labelOffsetsentry → label positioned outside square - inline — Otherwise → full label + stats centered inside square
Mobile (three tiers by square size):
- inline — side >=
mobileInlineSide→ full label + stats centered inside square - displaced — Has
mobileLabelOffsetsentry → code inside square + label outside - code — Otherwise → motivo code inside square + full text in HTML legend below
To move a motivo between displaced/code: add/remove from the corresponding offsets object (labelOffsets for desktop, mobileLabelOffsets for mobile).
legendOrder is captured once on first render via $effect, sorted by % descending for the initial filters (AGE, all population, 2025). Order stays fixed across filter changes to allow visual comparison.