Rafael Lopez

objetos

......@@ -45,7 +45,7 @@
scaleBand()
.domain(data.map(d => d.año))
.range([0, 100]) // porcentaje
.padding(0.2)
.padding(0.6)
);
// Para CSS bottom positioning: 0 → 0%, max → 100%
......@@ -112,15 +112,11 @@
<!-- Eje X -->
<div class="x-axis" style="height: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px">
{#each data as d, i}
{@const isFirst = i === 0}
{@const isLast = i === data.length - 1}
{@const isMid = i === midIndex && data.length > 5}
{@const showLabel = isFirst || isLast || isMid || hoveredYear === d.año}
<span
class="x-label"
class:visible={showLabel}
class="x-label visible"
class:hovered={hoveredYear === d.año}
>
{d.año}
{String(d.año).slice(-2)}
</span>
{/each}
</div>
......@@ -170,7 +166,7 @@
bottom: 0;
display: flex;
align-items: flex-end;
gap: 2px;
gap: 4px;
}
.bar-container {
......@@ -178,11 +174,12 @@
height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
cursor: default;
}
.bar {
width: 100%;
width: 80%;
background: #c4897d;
border-radius: 4px 4px 0 0;
transition: opacity 0.1s ease;
......@@ -206,20 +203,27 @@
bottom: 0;
display: flex;
align-items: center;
gap: 4px;
}
.x-label {
flex: 1;
text-align: center;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.6875rem;
font-family: 'DM Mono', monospace;
font-size: 0.5625rem;
color: var(--theme-texto);
opacity: 0;
transition: opacity 0.2s ease;
}
.x-label.visible {
opacity: 0.5;
}
.x-label.hovered {
opacity: 1;
color: var(--theme-titulo);
font-weight: 600;
}
.chart-empty {
......
......@@ -101,12 +101,14 @@
padding: 0.125rem 0;
background: transparent;
border: none;
border-bottom: 1px dotted #333333;
border-bottom: 1px dotted #666666;
color: var(--theme-titulo);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s;
max-width: 100%;
min-width: 0;
}
.entity-btn:hover {
......@@ -115,6 +117,9 @@
.entity-label {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
......@@ -132,7 +137,8 @@
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 280px;
right: 0;
min-width: min(280px, calc(100vw - 2rem));
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
......
<script>
import { goto } from '$app/navigation';
import { indexLoaded } from '$lib/stores/searchStore';
import { search } from '$lib/services/search';
let {
preserveVista = false,
......@@ -16,20 +14,46 @@
let selectedIdx = $state(-1);
let debounceTimer;
function parseMetadatos(meta) {
if (!meta) return {};
if (typeof meta === 'object') return meta;
try { return JSON.parse(meta); } catch { return {}; }
}
async function doSearch(query) {
isSearching = true;
try {
const params = new URLSearchParams({
q: query,
per_page: '20',
is_class: 'true',
class_: 'objeto'
});
const res = await fetch(`/api/search?${params}`);
if (!res.ok) throw new Error();
const data = await res.json();
localResults = (data.hits || []).map(hit => {
const meta = parseMetadatos(hit.document.metadatos);
const codigo = meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '';
const nombre = hit.document.texto;
const highlight = hit.highlights?.[0]?.snippet || nombre;
return { codigo, nombre, highlight };
});
selectedIdx = -1;
} catch {
localResults = [];
}
isSearching = false;
}
function handleSearchInput(e) {
const val = e.target.value;
searchVal = e.target.value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (val.length >= 2) {
isSearching = true;
// Buscar y filtrar solo objetos de gasto
const allResults = search(val, 50);
localResults = allResults.filter(r => r.tipo === 'objeto_gasto').slice(0, 15);
isSearching = false;
} else {
localResults = [];
}
}, 200);
if (searchVal.length >= 2) {
debounceTimer = setTimeout(() => doSearch(searchVal), 200);
} else {
localResults = [];
}
}
function handleSearchKeydown(e) {
......@@ -86,7 +110,6 @@
onfocus={() => searchFocused = true}
onblur={handleSearchBlur}
placeholder="Buscar objeto..."
disabled={!$indexLoaded}
/>
{#if searchVal}
<button class="search-clear" onclick={handleClear}>
......@@ -110,7 +133,7 @@
onmouseenter={() => selectedIdx = i}
>
<span class="item-code">{result.codigo}</span>
<span class="item-name">{result.nombre}</span>
<span class="item-name">{@html result.highlight}</span>
</button>
{/each}
{/if}
......@@ -229,4 +252,11 @@
font-size: 0.8125rem;
color: var(--theme-titulo);
}
:global(.item-name mark) {
background: rgba(201, 167, 81, 0.3);
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
</style>
......
......@@ -72,13 +72,13 @@
// Detectar tipos de clasificadores presentes en los resultados
// Mapeo inverso: API class_ → id interno del clasificador
const CLASS_REVERSE_MAP = { ubigeo: 'geografico', acteco: 'sectores' };
const CLASS_REVERSE_MAP = { ubigeo: 'geografico', acteco: 'sectores', finalidad: 'finfun' };
// Filtrar hits según clasificadores seleccionados (la API puede devolver tipos extra)
$: filteredHits = (() => {
if (searchMode !== 'clasificadores' || selectedClassifiers.length === 0) return allHits;
const allowedClasses = new Set(selectedClassifiers.map(c => {
const map = { geografico: 'ubigeo', sectores: 'acteco' };
const map = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' };
return map[c] || c;
}));
return allHits.filter(h => allowedClasses.has(h.document.class_));
......@@ -86,7 +86,12 @@
$: activeClassTypes = (() => {
const types = new Set();
filteredHits.forEach(h => { if (h.document.class_) types.add(h.document.class_); });
filteredHits.forEach(h => {
if (h.document.class_) {
const normalized = CLASS_REVERSE_MAP[h.document.class_] || h.document.class_;
types.add(normalized);
}
});
return types;
})();
$: isMixedClassSearch = activeClassTypes.size > 1;
......@@ -154,14 +159,14 @@
if (isMixedClassSearch || searchMode === 'todo') {
// Filtrar duplicados de finfun
const filtered = filteredHits.filter(hit => {
if (hit.document.class_ !== 'finfun') return true;
if (hit.document.class_ !== 'finfun' && hit.document.class_ !== 'finalidad') return true;
const m = parseMetadatos(hit.document.metadatos);
return !(m.finfun_funcion === 0 && m.finfun_grpfuncion !== undefined);
});
const groups = {};
filtered.forEach(hit => {
const type = hit.document.class_ || 'otros';
const type = CLASS_REVERSE_MAP[hit.document.class_] || hit.document.class_ || 'otros';
const groupName = CLASS_LABELS[type] || type;
if (!groups[groupName]) {
groups[groupName] = { name: groupName, hits: [], totalMonto: 0, classType: type };
......@@ -692,7 +697,7 @@
const isClassResult = hit.document.is_class === true;
let url;
const isFinfunClass = hit.document.class_ === 'finfun';
const isFinfunClass = hit.document.class_ === 'finfun' || hit.document.class_ === 'finalidad';
const isUbigeoClass = hit.document.class_ === 'ubigeo';
......@@ -1207,7 +1212,7 @@
{#each group.hits.slice(0, maxVisible) as hit, i (getHitKey(hit, gi * 1000 + i))}
{@const meta = parseMetadatos(hit.document.metadatos)}
{@const globalIdx = flatHitsForNav.indexOf(hit)}
{@const hitClass = hit.document.class_}
{@const hitClass = CLASS_REVERSE_MAP[hit.document.class_] || hit.document.class_}
{#if hitClass === 'entidad'}
{@const isDA = meta.da && meta.entidad_desc_entidad}
{@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
......@@ -1323,10 +1328,10 @@
{@const subarea = isEntidadClass ? extractAfterHyphen(meta.entidad_desc_subarea) : null}
{@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
{@const isObjetoClass = hit.document.class_ === 'objeto'}
{@const isFinfunClass = hit.document.class_ === 'finfun'}
{@const isFinfunClass = hit.document.class_ === 'finfun' || hit.document.class_ === 'finalidad'}
{@const isUbigeoClass = hit.document.class_ === 'ubigeo'}
{@const isClassResult = hit.document.is_class === true}
{@const hitType = hit.document.class_}
{@const hitType = CLASS_REVERSE_MAP[hit.document.class_] || hit.document.class_}
<a
href={isEntidadClass
? `/entidad/${meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad}`
......
This diff could not be displayed because it is too large.
This diff could not be displayed because it is too large.
......@@ -28,8 +28,8 @@
let topEntidadesPorAño = $state({});
const POBLACION = 12000000;
const objetoCodigo = '11700';
const objetoNivel = 'subpartida';
let objetoCodigo = $derived(objetoData.objeto);
let objetoNivel = $derived(objetoData.nivel);
let gastoPerCapita = $derived(
datosAnuales.map(d => ({
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código objeto
function getGrupoCode(objeto) {
return objeto.charAt(0) + '0000';
}
function getSubgrupoCode(objeto) {
return objeto.substring(0, 2) + '000';
}
function getPartidaCode(objeto) {
return objeto.substring(0, 3) + '00';
}
function getNivel(objeto) {
if (objeto.endsWith('0000')) return 'grupo';
if (objeto.endsWith('000')) return 'subgrupo';
if (objeto.endsWith('00')) return 'partida';
return 'subpartida';
}
export async function load({ params }) {
console.time('[SERVER] Total load objeto');
const { codigo } = params;
console.time('[SERVER] Query clas_objetos');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', codigo);
console.timeEnd('[SERVER] Query clas_objetos');
if (dbError || !data || data.length === 0) {
throw error(404, 'Objeto no encontrado');
}
// Tomar el primer resultado
const objeto = data[0];
const nivel = objeto.nivel || getNivel(codigo);
// Obtener jerarquía (padres e hijos)
let padres = [];
let hijos = [];
console.time('[SERVER] Query padres');
// Buscar padres según nivel
if (nivel === 'subpartida') {
// Padres: grupo, subgrupo, partida
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const partidaCode = getPartidaCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.in('objeto', [grupoCode, subgrupoCode, partidaCode])
.order('objeto');
if (padresData) padres = padresData;
} else if (nivel === 'partida') {
// Padres: grupo, subgrupo
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.in('objeto', [grupoCode, subgrupoCode])
.order('objeto');
if (padresData) padres = padresData;
} else if (nivel === 'subgrupo') {
// Padre: grupo
const grupoCode = getGrupoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', grupoCode);
if (padresData) padres = padresData;
}
// grupo no tiene padres
console.timeEnd('[SERVER] Query padres');
console.time('[SERVER] Query hijos');
// Buscar hijos según nivel
if (nivel === 'grupo') {
// Hijos: subgrupos que empiecen con el mismo dígito
const prefix = codigo.charAt(0);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subgrupo')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
} else if (nivel === 'subgrupo') {
// Hijos: partidas que empiecen con los mismos 2 dígitos
const prefix = codigo.substring(0, 2);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'partida')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
} else if (nivel === 'partida') {
// Hijos: subpartidas que empiecen con los mismos 3 dígitos
const prefix = codigo.substring(0, 3);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subpartida')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
}
// subpartida no tiene hijos
console.timeEnd('[SERVER] Query hijos');
console.timeEnd('[SERVER] Total load objeto');
return {
objeto,
padres,
hijos
};
}
<script>
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { supabase } from '$lib/supabase';
import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
import BarChart from '$lib/components/objeto/BarChart.svelte';
import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
import { initIndex } from '$lib/stores/searchStore';
let { data } = $props();
let objetoData = $derived(data.objeto);
let padres = $derived(data.padres);
let hijos = $derived(data.hijos);
let mounted = $state(false);
let loading = $state(true);
let hoveredYear = $state(null);
let showDefinicionesModal = $state(false);
let showMobileSidebar = $state(false);
let metrica = $state('percapita'); // 'percapita' | 'monto'
// Parsear descripciones
function parseDescripciones(str) {
if (!str) return [];
try {
const parsed = JSON.parse(str);
return parsed.sort((a, b) => {
const yA = a.rangos?.match(/\d{4}/g) || [];
const yB = b.rangos?.match(/\d{4}/g) || [];
return Math.max(...yB.map(Number), 0) - Math.max(...yA.map(Number), 0);
});
} catch { return []; }
}
let descripciones = $derived(parseDescripciones(objetoData.descripciones));
let variaciones = $derived(descripciones.map(d => ({ rango: d.rangos, texto: d.descripcion })));
// Estado selector entidad
let entidades = $state([]);
let selectedEntity = $state(null);
let nEntidades = $derived(data.nEntidades);
// Datos
let datosAnuales = $state([]);
let datosHistorico = $state(null);
let topEntidadesPorAño = $state({});
const POBLACION = 12000000;
let objetoCodigo = $derived(objetoData.objeto);
let objetoNivel = $derived(objetoData.nivel);
let gastoPerCapita = $derived(
datosAnuales.map(d => {
const monto = selectedEntity ? (d.monto ?? d.devengado) : d.total;
const perCapita = d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0);
return {
año: d.gestion,
monto,
perCapita: metrica === 'percapita' ? perCapita : monto
};
})
);
let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : datosHistorico.total) : 0);
let promedioPerCapita = $derived(datosHistorico?.per_capita || 0);
let totalPorcentaje = $derived(datosHistorico?.prop || 0);
let ranking = $derived(datosHistorico?.ranking || 0);
let nPares = $derived(datosHistorico?.n_pares || 0);
let currentData = $derived(
hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
);
let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
let displayMontoLabel = $derived(currentData ? `gastados en ${currentData.año}` : `gastados ${primerAño}-${ultimoAño}`);
let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
let displayPerCapitaLabel = $derived(currentData ? `por cada boliviano en ${currentData.año}` : 'por cada boliviano');
let displayPorcentaje = $derived(currentData ? (totalHistorico > 0 ? totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length)) : 0) : totalPorcentaje);
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${currentData ? (datosAnuales.find(d => d.gestion === hoveredYear)?.ranking || ranking) : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : nPares}`);
let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total');
let nivelKey = $derived(objetoData.nivel?.toLowerCase());
let nivelPlural = $derived({ grupo: 'grupos', subgrupo: 'subgrupos', partida: 'partidas', subpartida: 'subpartidas' }[nivelKey] || 'objetos');
let nivelLabel = $derived({ grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' }[nivelKey] || objetoData.nivel);
let displayRankingLabel = $derived(currentData ? `${nivelPlural} más gastadas en ${currentData.año}` : `${nivelPlural} más gastadas`);
// Top entidades
let topEntidadesTotal = $derived(
datosHistorico && !selectedEntity ? [
{ nombre: datosHistorico.top1_entidad, porcentaje: datosHistorico.top1_pct },
{ nombre: datosHistorico.top2_entidad, porcentaje: datosHistorico.top2_pct },
{ nombre: datosHistorico.top3_entidad, porcentaje: datosHistorico.top3_pct },
].filter(e => e.nombre) : []
);
let displayTopEntidades = $derived(
hoveredYear && topEntidadesPorAño[hoveredYear] ? topEntidadesPorAño[hoveredYear] : topEntidadesTotal
);
// Tweens
const tw = { duration: 300, easing: cubicOut };
const twMonto = tweened(0, tw);
const twPerCapita = tweened(0, tw);
const twPorcentaje = tweened(0, tw);
const twTop1 = tweened(0, tw);
const twTop2 = tweened(0, tw);
const twTop3 = tweened(0, tw);
$effect(() => { twMonto.set(displayMonto); });
$effect(() => { twPerCapita.set(displayPerCapita); });
$effect(() => { twPorcentaje.set(displayPorcentaje); });
$effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
$effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
$effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
function formatMontoLargo(n) {
if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
return n.toLocaleString('es-BO');
}
function formatPerCapita(n) {
return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
async function loadData() {
loading = true;
if (selectedEntity) {
const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*')
.eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', selectedEntity.entidad).order('gestion');
if (data) {
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion > 0);
topEntidadesPorAño = {};
}
} else {
const { data } = await supabase.schema('ppto').from('vista_objeto_estado').select('*')
.eq('objeto', objetoCodigo).order('gestion');
if (data) {
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion > 0);
topEntidadesPorAño = {};
datosAnuales.forEach(d => {
topEntidadesPorAño[d.gestion] = [
{ nombre: d.top1_entidad, porcentaje: d.top1_pct },
{ nombre: d.top2_entidad, porcentaje: d.top2_pct },
{ nombre: d.top3_entidad, porcentaje: d.top3_pct },
].filter(e => e.nombre);
});
}
}
loading = false;
}
async function loadEntidades() {
const { data } = await supabase.schema('ppto').from('entidades_por_objeto').select('entidad, entidad_desc')
.eq('objeto', objetoCodigo).eq('nivel', objetoNivel).order('entidad_desc');
if (data) entidades = data;
}
onMount(async () => {
mounted = true;
initIndex();
await Promise.all([loadData(), loadEntidades()]);
});
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</svelte:head>
<div class="page" class:mounted>
<nav class="page-nav">
<a href="/clasificadores/objeto-gasto" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Clasificador
</a>
</nav>
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
<!-- Mobile toggle -->
<button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>{showMobileSidebar ? 'Ocultar herramientas' : 'Herramientas'}</span>
</button>
<div class="context-card">
<h2 class="context-main-title">Herramientas</h2>
<!-- Buscar -->
<div class="context-section">
<h3 class="context-subtitle">Buscar</h3>
<p class="context-hint">Buscar otro objeto de gasto</p>
<ObjetoSearch />
</div>
<div class="context-separator"></div>
<!-- Navegación -->
{#if padres.length > 0 || hijos.length > 0}
<div class="context-section">
<h3 class="context-subtitle">Navegación</h3>
<p class="context-hint">Partidas relacionadas. Haz click para explorar.</p>
<div class="tree">
{#each padres as padre, i}
<a href="/objeto/{padre.objeto}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{padre.objeto}</span>
<span class="tree-name">{padre.desc_objeto}</span>
</a>
{/each}
<div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{objetoData.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{objetoData.objeto}</span>
<span class="tree-name">{objetoData.desc_objeto}</span>
</div>
{#if hijos.length > 0}
<div class="tree-children" style="--indent: {padres.length + 1}">
{#each hijos as hijo}
<a href="/objeto/{hijo.objeto}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{hijo.objeto}</span>
<span class="tree-name">{hijo.desc_objeto}</span>
</a>
{/each}
</div>
{/if}
</div>
</div>
<div class="context-separator"></div>
{/if}
<!-- Explorar -->
<div class="context-section">
<h3 class="context-subtitle">Explorar visualizaciones</h3>
<div class="explore-scroll">
<a href="/objeto/{objetoData.objeto}?vista=comparar" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/>
</svg>
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
</svg>
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades que gastan en {objetoData.desc_objeto?.toLowerCase()}</span>
</a>
<a href="/ubicacion" class="explore-card explore-card-disabled">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
</svg>
<span class="explore-title">Mapa</span>
<span class="explore-desc">Dónde se concentra el gasto en {objetoData.desc_objeto?.toLowerCase()}</span>
</a>
<a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<circle cx="12" cy="12" r="10"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10A15 15 0 0 1 12 2z"/>
</svg>
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
</svg>
<span class="explore-title">Clasificador</span>
<span class="explore-desc">Ver todos los objetos de gasto y navegar entre ellos</span>
</a>
</div>
</div>
<div class="context-separator"></div>
<!-- Comparabilidad -->
<div class="context-section">
<h3 class="context-subtitle">Comparabilidad</h3>
<div class="vigencia-compact">
<div class="vigencia-status-icon vigencia-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="vigencia-text">
<span class="vigencia-main">Comparable en todo el período</span>
<span class="vigencia-sub">2005 – 2025</span>
</div>
</div>
</div>
<div class="context-separator"></div>
<!-- Definición -->
<div class="context-section">
<h3 class="context-subtitle">Definición</h3>
<button class="def-link" onclick={() => showDefinicionesModal = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
{#if variaciones.length === 1}
Ver definición oficial
{:else}
Ver {variaciones.length} variaciones en la definición
{/if}
</button>
</div>
</div>
</aside>
<!-- Main -->
<main class="dashboard-main">
<div class="chart-protagonista">
<!-- Header -->
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">{nivelLabel}</span>
<span class="pill pill-codigo">{objetoData.objeto}</span>
</div>
</div>
<h2 class="main-title-name">{objetoData.desc_objeto}</h2>
<div class="chart-controls">
<EntitySelector {entidades} bind:selectedEntity {nEntidades} />
</div>
<div class="chart-header">
<span class="chart-title">
{metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
<span class="chart-period">· {displayPeriodo}</span>
</span>
<button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
{metrica === 'percapita' ? 'Ver monto real' : 'Ver per cápita'}
</button>
</div>
{#if loading}
<div class="chart-loading" style="flex: 1; min-height: 100px;">
<span>Cargando...</span>
</div>
{:else}
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
</div>
{/if}
<!-- KPIs -->
<div class="kpis">
<div class="kpi">
<span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
<span class="kpi-label">{displayMontoLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
<span class="kpi-label">{displayPerCapitaLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="kpi-label">{displayPorcentajeLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayRanking}</span>
<span class="kpi-label">{displayRankingLabel}</span>
</div>
</div>
</div>
{#if !selectedEntity && displayTopEntidades.length > 0}
<div class="ranking-section">
<h3 class="ranking-title">Concentración del gasto</h3>
<p class="ranking-desc">Porcentaje del total gastado en {objetoData.desc_objeto?.toLowerCase()} por todo el Estado {hoveredYear ? `en ${hoveredYear}` : `(${primerAño}-${ultimoAño})`}</p>
<div class="ranking-cards">
{#each displayTopEntidades as ent, i}
{@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
<div class="ranking-card">
<span class="ranking-position">#{i + 1}</span>
<span class="ranking-name">{ent.nombre}</span>
<span class="ranking-pct">{pct.toFixed(1)}%</span>
</div>
{/each}
</div>
</div>
{/if}
</main>
</div>
<!-- Modal de definiciones -->
{#if showDefinicionesModal}
<div class="modal-overlay" onclick={() => showDefinicionesModal = false}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Definiciones de "{objetoData.desc_objeto}"</h3>
<button class="modal-close" onclick={() => showDefinicionesModal = false}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
{#each variaciones as v, i}
<div class="def-item" class:def-current={i === 0}>
<div class="def-header">
<span class="def-period">{v.rango}</span>
{#if i === 0}
<span class="def-badge">Vigente</span>
{/if}
</div>
<p class="def-text">{v.texto}</p>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<style>
/* ═══════════════════════════════════════════════════════════════
PAGE
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
width: 100%;
max-width: 100%;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s ease;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page {
background: #f5f5f7;
}
/* ═══════════════════════════════════════════════════════════════
NAV
═══════════════════════════════════════════════════════════════ */
.page-nav {
padding: 2rem 2rem 0.5rem;
max-width: 1400px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
text-decoration: none;
transition: color 0.2s;
}
.back-link:hover { color: var(--theme-accent); }
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: minmax(200px, 30%) 1fr;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 30px 2rem;
height: calc(100vh - 4rem);
overflow: hidden;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
display: flex;
flex-direction: column;
min-height: 0;
}
.context-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.context-card::-webkit-scrollbar { width: 4px; }
.context-card::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.context-main-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.5;
}
.context-subtitle {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.875rem;
font-weight: 700;
color: var(--theme-titulo);
margin: 0;
}
.context-hint {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
margin: 0;
line-height: 1.4;
}
.context-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.context-separator {
border-top: 1px solid var(--theme-borde);
margin: 0.25rem 0;
}
/* ═══════════════════════════════════════════════════════════════
TREE
═══════════════════════════════════════════════════════════════ */
.tree {
font-size: 0.75rem;
line-height: 1.6;
}
.tree-node {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
margin-left: calc(var(--indent, 0) * 1rem);
border-left: 2px solid transparent;
border-radius: 0 6px 6px 0;
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
}
.tree-node:hover { color: var(--theme-accent); }
.tree-node-current {
color: var(--theme-titulo);
font-weight: 600;
border-left-color: var(--theme-accent);
background: rgba(201, 167, 81, 0.1);
}
.tree-children {
margin-left: calc(var(--indent, 0) * 1rem);
}
.tree-node-child {
padding-left: 0.75rem;
}
.tree-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
opacity: 0.4;
}
.tree-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
min-width: 2.5rem;
opacity: 0.6;
}
.tree-name {
font-size: 0.6875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════════════════════════════
EXPLORE
═══════════════════════════════════════════════════════════════ */
.explore-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.explore-scroll::-webkit-scrollbar { display: none; }
.explore-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0.375rem 0.5rem;
width: 120px;
height: 95px;
border-radius: 8px;
background: var(--theme-borde);
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
flex-shrink: 0;
overflow: hidden;
}
.explore-card:hover {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.explore-card:hover .explore-watermark {
opacity: 0.18;
color: var(--theme-accent);
}
.explore-card:hover .explore-title {
color: var(--theme-accent);
}
.explore-watermark {
position: absolute;
bottom: -4px;
right: -4px;
width: 44px;
height: 44px;
opacity: 0.15;
pointer-events: none;
color: var(--theme-accent, #C9A751);
}
.explore-title {
position: relative;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
color: var(--theme-titulo);
z-index: 1;
}
.explore-desc {
position: relative;
font-size: 0.625rem;
line-height: 1.35;
opacity: 0.6;
margin-top: 3px;
z-index: 1;
}
.explore-card-disabled {
opacity: 0.3;
pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════
VIGENCIA
═══════════════════════════════════════════════════════════════ */
.vigencia-compact {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vigencia-status-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vigencia-status-icon.vigencia-full {
background: rgba(74, 158, 107, 0.15);
color: #4A9E6B;
}
.vigencia-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vigencia-main {
font-size: 0.75rem;
font-weight: 500;
color: var(--theme-titulo);
}
.vigencia-sub {
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
}
/* ═══════════════════════════════════════════════════════════════
DEFINICIÓN
═══════════════════════════════════════════════════════════════ */
.def-link {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-accent, #C9A751);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.def-link:hover { opacity: 0.7; }
.def-link svg { flex-shrink: 0; opacity: 0.8; }
/* ═══════════════════════════════════════════════════════════════
MAIN
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
overflow: hidden;
}
.chart-protagonista {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
min-height: 0;
flex: 2;
overflow: hidden;
}
.ranking-section {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ═══════════════════════════════════════════════════════════════
CHART & KPIs
═══════════════════════════════════════════════════════════════ */
.chart-top-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.main-title-pills {
display: flex;
gap: 0.375rem;
}
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.pill-codigo {
background: var(--theme-borde);
color: var(--theme-texto);
}
.main-title-name {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.15;
color: var(--theme-titulo);
}
.chart-controls {
margin-bottom: 0.5rem;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.chart-title {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
.chart-period {
opacity: 0.5;
}
.metrica-toggle {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-accent);
background: rgba(201, 167, 81, 0.1);
border: 1px solid rgba(201, 167, 81, 0.2);
padding: 0.2rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.metrica-toggle:hover {
background: rgba(201, 167, 81, 0.2);
}
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
}
.chart-flex-wrapper {
flex: 1;
min-height: 0;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--theme-borde);
}
.kpi {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
text-align: center;
}
.kpi-value {
font-family: 'Qanelas', var(--font-sans);
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
}
.kpi-label {
font-size: 0.625rem;
color: var(--theme-texto);
opacity: 0.5;
line-height: 1.35;
}
/* ═══════════════════════════════════════════════════════════════
RANKING
═══════════════════════════════════════════════════════════════ */
.ranking-title {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.25rem;
}
.ranking-desc {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.5;
margin: 0 0 0.5rem;
}
.ranking-cards {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ranking-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.625rem;
border-radius: 8px;
background: rgba(128, 128, 128, 0.06);
}
.ranking-position {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
font-weight: 600;
min-width: 1.5rem;
}
.ranking-name {
flex: 1;
font-size: 0.8125rem;
color: var(--theme-titulo);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ranking-pct {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-titulo);
}
/* ═══════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: var(--theme-surface);
border-radius: 16px;
max-width: 560px;
width: 100%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--theme-borde);
}
.modal-header h3 {
font-size: 1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
}
.modal-close {
color: var(--theme-texto);
background: transparent;
border: none;
padding: 0.25rem;
cursor: pointer;
border-radius: 6px;
transition: background 0.2s;
}
.modal-close:hover {
background: var(--theme-borde);
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.def-item {
padding: 1rem;
background: var(--theme-body);
border-radius: 10px;
border-left: 3px solid var(--theme-borde);
}
.def-current {
border-left-color: #6B9FD4;
}
.def-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.def-period {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
font-weight: 500;
color: #6B9FD4;
}
.def-badge {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: white;
background: #4A9E6B;
padding: 0.125rem 0.5rem;
border-radius: 4px;
}
.def-text {
font-size: 0.875rem;
color: var(--theme-texto);
line-height: 1.6;
margin: 0;
}
:global(html.dark) .def-current {
border-left-color: var(--theme-accent);
}
:global(html.dark) .def-period {
color: var(--theme-accent);
}
/* ═══════════════════════════════════════════════════════════════
MOBILE SIDEBAR
═══════════════════════════════════════════════════════════════ */
.mobile-sidebar-toggle {
display: none;
}
@media (max-width: 900px) {
.dashboard-layout {
grid-template-columns: 1fr;
gap: 0.5rem;
height: auto;
overflow: visible;
padding: 1.5rem 1rem 2rem;
align-content: center;
min-height: calc(100vh - 4rem);
}
.dashboard-main {
overflow: visible;
}
.chart-protagonista {
flex: none;
}
.ranking-section {
flex: none;
}
.mobile-sidebar-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
color: var(--theme-titulo);
font-family: 'Qanelas', var(--font-sans);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 0.25rem;
width: 100%;
}
.mobile-sidebar-toggle:hover {
background: var(--theme-surface-hover);
}
.mobile-sidebar-toggle svg {
color: var(--theme-texto);
opacity: 0.6;
}
.dashboard-context .context-card {
display: none;
}
.dashboard-context.mobile-open .context-card {
display: flex;
animation: slideDown 0.25s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.context-card {
height: auto;
overflow-y: visible;
}
.page-nav {
padding: 0.75rem 1rem;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.dashboard-layout {
padding: 0.5rem;
}
.page-nav {
padding: 0.5rem;
}
.chart-protagonista,
.ranking-section,
.context-card {
padding: 0.5rem;
border-radius: 8px;
}
.main-title-name {
font-size: 1.375rem;
}
.chart-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
.kpi-value {
font-size: 0.8125rem;
}
.kpi-label {
font-size: 0.5625rem;
}
.explore-scroll {
gap: 6px;
}
.explore-card {
width: 100px;
height: 80px;
}
.explore-title {
font-size: 0.625rem;
}
.explore-desc {
font-size: 0.5rem;
}
.ranking-title {
font-size: 0.875rem;
}
.ranking-name {
font-size: 0.6875rem;
}
.ranking-pct {
font-size: 0.75rem;
}
.modal-content {
margin: 0.5rem;
max-height: 90vh;
}
.tree-name {
max-width: 150px;
}
}
/* ═══════════════════════════════════════════════════════════════
DARK MODE
═══════════════════════════════════════════════════════════════ */
:global(html.dark) .context-hint {
color: rgba(255, 255, 255, 0.5);
}
:global(html.dark) .def-link {
color: var(--theme-accent, #C9A751);
}
</style>