Rafael Lopez

template obejtos agregado

A fines de 2025 decidimos abrir los datos históricos de presupuesto del Estado boliviano.
Cada año el Estado recibe y gasta dinero a tu nombre. ¿Sabes de dónde viene, en qué se usa y qué se logra con él? Esa información es tuya. Siempre lo fue, pero durante décadas permaneció encerrada en archivos PDF, en sistemas que pocos saben usar y en las paredes de un ministerio.
Hoy cambia eso.
Aquí puedes explorar más de veinte años de ejecución presupuestaria, que serán actualizados semanalmente. Puedes ver qué hizo el Estado en tu región, en tu sector y en el año que te interesa. Puedes comparar, cuestionar y entender. Y si encuentras algo que no cuadra o que vale la pena contar, puedes hacerlo tuyo.
No publicamos ésto para cumplir un trámite de transparencia. Lo hacemos porque creemos que un Estado que trabaja de espaldas a su sociedad se vuelve impune, y una sociedad que no entiende al Estado se desconecta de él.
Un presupuesto abierto es una apuesta: que la confianza se construye con información y no con promesas.
Abrimos para no cerrar.
# La forma
Existen 2 bases del presupuesto: gastos e ingresos, que registran el destino y origen de los recursos públicos respectivamente. Ambas bases cubren información sobre todas las entidades del Estado entre 2005 y 2025.
Cada registro de gasto o ingreso está compuesto de atributos que permiten identificarlo y de montos en bolivianos nominales. Los atributos que identifican a cada gasto son:
- Clasificador institucional: la entidad pública y dirección administrativa que registra el gasto.
- Categoría programática: el programa, proyecto o actividad al que se dirige.
- Objeto de gasto: el tipo de gasto según un clasificador estandarizado.
- Financiamiento: las fuentes de los recursos utilzados.
Mientras que aquellos que identifican a un ingreso son:
- Clasificador institucional: la entidad pública que registra el ingreso.
- Rubro de ingreso: el tipo de ingreso según un clasificador estandarizado.
- Financiamiento: las fuentes del ingreso.
Cada registro incluye 4 montos:
- Inicial: lo registrado en el presupuesto a inicio de la gestión.
- Vigente: el presupuesto luego de modificaciones durante la gestión.
- Devengado: el monto devengado para ejecución.
- Ejecutado: el monto pagado o percibido.
Además, cuando están disponibles, contamos con atributos adicionales para describir mejor el destino de los gastos. Éstos son finalidad y función, sector económico y ubicación geográfica.
# El detalle
## Gastos
La base de gastos nos permite comprender el destino de los recursos públicos.
- **Quién**: entidad y dirección administrativa responsable.
- **Qué**: programas, proyectos y actividades a los que se dirigen recursos.
- **Para qué**: finalidad y función pública.
- **En qué**: objetos de gasto.
- **Con qué recursos**: fuentes de las que se toman recursos.
- **Cuánto**: montos de presupuesto y ejecución.
Los campos en esta base son los siguientes.
### Gestión
#### gestion
Año fiscal del registro presupuestario.
Tipo: Número entero de 4 dígitos.
Ejemplo: `2005`.
### Clasificador institucional
Información sobre qué entidad y dirección administrativa registra el gasto. Una dirección administrativa pertenece a una entidad que a su vez está posicionada dentro de una jerarquía en el aparato estatal:
```
Sector
└─ Subsector
└─ Área
└─ Subárea
└─ Entidad
└─ Dirección Administrativa
```
#### entidad
Código de la entidad pública a la que pertenece el registro. Cada entidad ocupa una posición en el Sector Público que puede ser descrita en una jerarquía de 4 niveles: sector, subsector, área y subárea. Para cada nivel contamos con un código, sigla y descripción. Dentro de cada entidad, el gasto es registrado en una dirección administrativa, a la que podemos describir con un código y descripción.
Tipo: Número entero de 1 a 4 dígitos.
Ejemplo: `10`.
#### entidad_desc_entidad
Descripción de la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Ministerio De Relaciones Exteriores Y Culto`.
#### entidad_sigla_entidad
Sigla de la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `MIN-RREE`.
#### entidad_sector
Código del sector al que pertenece la entidad pública.
Tipo: Número entero de 1 dígito.
Ejemplo: `1`.
#### entidad_subsector
Código del subsector al que pertenece la entidad pública.
Tipo: Secuencia de números enteros separados por puntos.
Ejemplo: `1.10`.
#### entidad_area
Código del área al que pertenece la entidad pública.
Tipo: Secuencia de números enteros separados por puntos.
Ejemplo: `1.1.1`.
#### entidad_subarea
Código de la subárea al que pertenece la entidad pública.
Tipo: Secuencia de números enteros separados por puntos.
Ejemplo: `1.1.1.1.1`.
#### entidad_desc_sector
Descripción del sector al que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Sector Público`.
#### entidad_desc_subsector
Descripción del subsector al que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Sector Público No Financiero`.
#### entidad_desc_area
Descripción del área al que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Administraciones Públicas (Gob. General)`.
#### entidad_desc_subarea
Descripción de la subárea a la que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Administración Central`.
#### entidad_sigla_sector
Sigla del sector al que pertenece la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `SP`.
#### entidad_sigla_subsector
Sigla del subsector al que pertenece la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `SPNF`.
#### entidad_sigla_area
Sigla del área al que pertenece la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `GOBGEN`.
#### entidad_sigla_subarea
Sigla de la subárea a la que pertenece la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `ADMCEN`.
#### da
Código de la dirección administrativa que registra el gasto dentro de la entidad pública.
Tipo: Número entero de 1 a 2 dígitos.
Ejemplo: `1`.
#### desc_da
Descripción de la dirección administrativa que registra el gasto dentro de la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `DIRECCION SUPERIOR Y ADMINISTRACION CENTRAL`.
### Categoría programática
Información sobre los programas, proyectos y actividades a los que se dedica el gasto. Un programa puede contener múltiples proyectos o actividades.
```
Programa
├─ Proyecto
└─ Actividad
```
#### programa
Código del programa al que se dedica el gasto. Cada programa puede tener múltiples proyectos y/o actividades.
Tipo: Número entero de 1 a 3 dígitos.
Ejemplo: `52`.
#### proyecto
Código del proyecto al que se dedica el gasto. Este código puede corresponder al código SISIN asignado por el Viceministerio de Inversión Pública y Financiamiento Externo, o un código corto asignado por la entidad pública. Cuando el código es 0, la partida corresponde a una actividad.
Tipo: Código alfanumérico.
Ejemplo: `000100000200000`.
#### actividad
Código de la actividad a la que se dedica el gasto. Cuando es 0, la partida corresponde a un proyecto.
Tipo: Número entero de 1 dígito.
Ejemplo: `0`.
#### desc_programa
Descripción del programa al que se dedica el gasto.
Tipo: Texto descriptivo.
Ejemplo: `PROYECTO DE FORTALECIMIENTO INSTITUCIONAL - CONSTRUCCIÓN DEL EDIFICIO INTELIGENTE DE LA CANCILLERÍA`.
#### desc_actividad_proyecto
Descripción del proyecto o actividad al que se dedica el gasto, según corresponda.
Tipo: Texto descriptivo.
Ejemplo: `CONST. EDIFICIO INTELIGENTE MRE LA PAZ`.
### Financiamiento
Información sobre el origen del financiamiento para el gasto. Consiste en una fuente de financiamiento, organismo financiador y entidad de transferencia.
Cada fuente es descrita en 2 niveles: grupo y fuente.
```
Grupo
└─ Fuente
```
Cada organismo incluye un código, sigla y descripción. Cada entidad de transferencia consiste en un código de entidad similar a los códigos de las entidades que realizan el gasto.
#### grupo_fuente
Código del grupo de la fuente de financiamiento.
Tipo: Número entero de 1 dígito.
Ejemplo: `1`.
#### fuente
Código de la fuente de financiamiento.
Tipo: Número entero de 2 dígitos.
Ejemplo: `10`.
#### desc_grupo_fuente
Descripción del grupo de la fuente de financiamiento.
Tipo: Texto descriptivo.
Ejemplo: `TESORO GENERAL`.
#### desc_fuente
Descripción de la fuente de financiamiento.
Tipo: Texto descriptivo.
Ejemplo: `Tesoro General de la Nación`.
#### organismo
Código del organismo financiador del gasto.
Tipo: Número entero de 3 dígitos.
Ejemplo: `111`.
#### desc_organismo
Descripción del organismo financiador.
Tipo: Texto descriptivo.
Ejemplo: `Tesoro General de la Nación`.
#### sigla_organismo
Sigla del organismo financiador.
Tipo: Texto corto (sigla).
Ejemplo: `TGN`.
#### entidad_transferencia
Código de la entidad de la que se transfieren recursos de financiamiento cuando corresponde.
Tipo: Número entero de 1 a 4 dígitos.
Ejemplo: `2351`.
### Objeto del gasto
Información sobre la naturaleza económica del gasto. Cada objeto de gasto es descrito en 4 niveles: grupo, subgrupo, partida y subpartida, y cada nivel cuenta con un código y una descripción.
```
Grupo
└─ Subgrupo
└─ Partida
└─ Subpartida
```
#### objeto_grupo
Código del grupo del objeto de gasto.
Tipo: Número entero de 1 dígito.
Ejemplo: `1`.
#### objeto_subgrupo
Código del subgrupo del objeto de gasto.
Tipo: Número entero de 1 dígito.
Ejemplo: `1`.
#### objeto_partida
Código de la partida del objeto de gasto.
Tipo: Número entero de 1 dígito.
Ejemplo: `2`.
#### objeto_subpartida
Código de la subpartida del objeto de gasto.
Tipo: Número entero de 2 dígitos.
Ejemplo: `20`.
#### objeto_desc_grupo
Descripción del grupo del objeto de gasto.
Tipo: Texto descriptivo.
Ejemplo: `SERVICIOS PERSONALES`.
#### objeto_desc_subgrupo
Descripción del subgrupo del objeto de gasto.
Tipo: Texto descriptivo.
Ejemplo: `Empleados Permanentes`.
#### objeto_desc_partida
Descripción de la partida del objeto de gasto.
Tipo: Texto descriptivo.
Ejemplo: `Bono de Antiguedad`.
#### objeto_desc_subpartida
Descripción de la subpartida del objeto de gasto.
Tipo: Texto descriptivo.
Ejemplo: `Bono de Antigûedad`.
### Finalidad y función
Información sobre el propósito público del gasto. Consiste en clasificaciones según la finalidad y función, el sector económico y la ubicación geográfica.
La finalidad y función es descrita en 3 niveles: finalidad, grupo de función y función.
```
Finalidad
└─ Grupo de Función
└─ Función
```
El sector económico describe el sector donde el gasto tiene impacto directo y consiste en 3 niveles: sector, subsector y actividad.
```
Sector
└─ Subsector
└─ Actividad
```
La ubicación geográfica describe el lugar donde el gasto tiene impacto directo y tiene 3 niveles: departamento, provincia y municipio.
```
Departamento
└─ Provincia
└─ Municipio
```
#### finfun_finalidad
Código de finalidad del gasto.
Tipo: Número entero de 1 a 2 dígitos.
Ejemplo: `8`.
#### finfun_grpfuncion
Código del grupo de función del gasto.
Tipo: Número entero de 1 a 2 dígitos.
Ejemplo: `2`.
#### finfun_funcion
Código de la función del gasto.
Tipo: Número entero de 1 dígito.
Ejemplo: `0`.
#### finfun_desc_finalidad
Descripción de la finalidad del gasto.
Tipo: Texto descriptivo.
Ejemplo: `ACTIVIDADES RECREATIVAS, CULTURA Y RELIGION`.
#### finfun_desc_grpfuncion
Descripción del grupo de la función del gasto.
Tipo: Texto descriptivo.
Ejemplo: `Servicios culturales`.
#### finfun_desc_funcion
Descripción de la función del gasto.
Tipo: Texto descriptivo.
Ejemplo: `Servicios culturales`.
#### acteco_sector
Código del sector económico donde tiene impacto directo el gasto.
Tipo: Número entero de 1 a 2 dígitos.
Ejemplo: `9`.
#### acteco_subsector
Código del subsector económico donde tiene impacto directo el gasto.
Tipo: Número entero de 1 dígito.
Ejemplo: `4`.
#### acteco_actividad
Código de la actividad económica donde tiene impacto directo el gasto.
Tipo: Número entero de 1 a 2 dígitos.
Ejemplo: `1`.
#### acteco_desc_sector
Descripción del sector económico donde tiene impacto directo el gasto.
Tipo: Texto descriptivo.
Ejemplo: `EDUCACION Y CULTURA`.
#### acteco_desc_subsector
Descripción del subsector económico donde tiene impacto directo el gasto.
Tipo: Texto descriptivo.
Ejemplo: `CULTURA`.
#### acteco_desc_actividad
Descripción de la actividad económica donde tiene impacto directo el gasto.
Tipo: Texto descriptivo.
Ejemplo: `CONSTRUCCION, REFACCION Y EQUIP. DE CENTROS CULTURALES`.
#### departamento
Código del departamento donde tiene impacto directo el gasto.
Tipo: Número entero de 1 a 2 dígitos.
Ejemplo: `2`.
#### provincia
Código de la provincia donde tiene impacto directo el gasto.
Tipo: Número entero de 1 a 2 dígitos.
Ejemplo: `6`.
#### municipio
Código del municipio donde tiene impacto directo el gasto.
Tipo: Número entero de 1 a 2 dígitos.
Ejemplo: `1`.
#### desc_departamento
Descripción del departamento donde tiene impacto directo el gasto.
Tipo: Texto descriptivo.
Ejemplo: `LA PAZ`.
#### desc_provincia
Descripción de la provincia donde tiene impacto directo el gasto.
Tipo: Texto descriptivo.
Ejemplo: `OMASUYOS`.
#### desc_municipio
Descripción del municipio donde tiene impacto directo el gasto.
Tipo: Texto descriptivo.
Ejemplo: `Achacachi`.
### Montos
Montos en bolivianos corrientes que hacen al presupuesto y ejecución de cada gasto. Incluye 2 montos de presupuesto: al inicio de la gestión y luego de modificaciones, y 2 montos de ejecución: devengado y pagado.
#### inicial
Monto presupuestado al inicio de la gestión, en bolivianos corrientes.
Tipo: Monto en bolivianos corrientes con dos decimales.
Ejemplo: `1411136`.
#### vigente
Monto presupuestado luego de modificaciones durante la gestión, en bolivianos corrientes.
Tipo: Monto en bolivianos corrientes con dos decimales.
Ejemplo: `514479`.
#### devengado
Monto devengado para la ejecución, en bolivianos corrientes.
Tipo: Monto en bolivianos corrientes con dos decimales.
Ejemplo: `493141.14`.
#### pagado
Monto pagado, en bolivianos corrientes.
Tipo: Monto en bolivianos corrientes con dos decimales.
Ejemplo: `493141.14`.
## Ingresos
La tabla de ingresos nos permite comprender el origen de los recursos públicos.
- **Quién**: entidad que registra el ingreso.
- **Qué recursos**: rubros del ingreso.
- **De dónde**: fuentes de financiamiento.
- **Cuánto**: montos de presupuesto y ejecución.
### Gestión
#### gestion
Año fiscal del registro presupuestario.
Tipo: Número entero de 4 dígitos.
Ejemplo: `2005`.
### Clasificador institucional
Información sobre qué entidad percibe el ingreso. Una entidad ocupa una posición dentro de la jerarquía del aparato estatal:
```
Sector
└─ Subsector
└─ Área
└─ Subárea
└─ Entidad
```
#### entidad
Código de la entidad pública a la que pertenece el registro. Cada entidad ocupa una posición en el Sector Público que puede describirse en una jerarquía de 4 niveles: sector, subsector, área y subárea. Para cada nivel se cuenta con un código, sigla y descripción.
Tipo: Número entero de 1 a 4 dígitos.
Ejemplo: `10`.
#### entidad_desc_entidad
Descripción de la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Ministerio De Relaciones Exteriores Y Culto`.
#### entidad_sigla_entidad
Sigla de la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `MIN-RREE`.
#### entidad_sector
Código del sector al que pertenece la entidad pública.
Tipo: Número entero de 1 dígito.
Ejemplo: `1`.
#### entidad_subsector
Código del subsector al que pertenece la entidad pública.
Tipo: Secuencia de números enteros separados por puntos.
Ejemplo: `1.10`.
#### entidad_area
Código del área al que pertenece la entidad pública.
Tipo: Secuencia de números enteros separados por puntos.
Ejemplo: `1.1.1`.
#### entidad_subarea
Código de la subárea al que pertenece la entidad pública.
Tipo: Secuencia de números enteros separados por puntos.
Ejemplo: `1.1.1.1.1`.
#### entidad_desc_sector
Descripción del sector al que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Sector Público`.
#### entidad_desc_subsector
Descripción del subsector al que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Sector Público No Financiero`.
#### entidad_desc_area
Descripción del área al que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Administraciones Públicas (Gob. General)`.
#### entidad_desc_subarea
Descripción de la subárea a la que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: `Administración Central`.
#### entidad_sigla_sector
Sigla del sector al que pertenece la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `SP`.
#### entidad_sigla_subsector
Sigla del subsector al que pertenece la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `SPNF`.
#### entidad_sigla_area
Sigla del área al que pertenece la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `GOBGEN`.
#### entidad_sigla_subarea
Sigla de la subárea a la que pertenece la entidad pública.
Tipo: Texto corto (sigla).
Ejemplo: `ADMCEN`.
### Financiamiento
Información sobre el origen del financiamiento del ingreso. Consiste en una fuente de financiamiento, un organismo financiador y una entidad otorgante. Cada fuente se describe en 2 niveles: grupo y fuente.
```
Grupo
└─ Fuente
```
Cada organismo incluye un código, sigla y descripción. La entidad otorgante se registra con un código de entidad.
#### grupo_fuente
Código del grupo de la fuente de financiamiento.
Tipo: Número entero de 1 dígito.
Ejemplo: `2`.
#### fuente
Código de la fuente de financiamiento.
Tipo: Número entero de 2 dígitos.
Ejemplo: `11`.
#### desc_grupo_fuente
Descripción del grupo de la fuente de financiamiento.
Tipo: Texto descriptivo.
Ejemplo: `T.G.N. OTROS INGRESOS`.
#### desc_fuente
Descripción de la fuente de financiamiento.
Tipo: Texto descriptivo.
Ejemplo: `T.G.N. Otros Ingresos`.
#### organismo
Código del organismo financiador del ingreso.
Tipo: Número entero de 3 dígitos.
Ejemplo: `0`.
#### desc_organismo
Descripción del organismo financiador.
Tipo: Texto descriptivo.
Ejemplo: `NC`.
#### sigla_organismo
Sigla del organismo financiador.
Tipo: Texto corto (sigla).
Ejemplo: `NC`.
#### entidad_otorgante
Código de la entidad de la que se transfieren recursos de financiamiento cuando corresponde.
Tipo: Número entero de 1 a 4 dígitos.
Ejemplo: `0`.
### Rubro del ingreso
Información sobre el origen de los recursos. Cada rubro se describe en 4 niveles: tipo, clase, cuenta y subcuenta, y cada nivel cuenta con un código y una descripción.
```
Tipo
└─ Clase
└─ Cuenta
└─ Subcuenta
```
#### rubro_tipo
Código del tipo de rubro.
Tipo: Número entero de 2 dígitos.
Ejemplo: `18`.
#### rubro_clase
Código de la clase de rubro.
Tipo: Número entero de 1 dígito.
Ejemplo: `2`.
#### rubro_cuenta
Código de la cuenta de rubro.
Tipo: Número entero de 1 dígito.
Ejemplo: `1`.
#### rubro_sub_cuenta
Código de la subcuenta de rubro.
Tipo: Número entero de 1 dígito.
Ejemplo: `1`.
#### rubro_desc_tipo
Descripción del tipo de rubro.
Tipo: Texto descriptivo.
Ejemplo: `DONACIONES CORRIENTES`.
#### rubro_desc_clase
Descripción de la clase de rubro.
Tipo: Texto descriptivo.
Ejemplo: `Donaciones Corrientes del Exterior`.
#### rubro_desc_cuenta
Descripción de la cuenta de rubro.
Tipo: Texto descriptivo.
Ejemplo: `Del Sector Privado del Exterior`.
#### rubro_desc_sub_cuenta
Descripción de la subcuenta de rubro.
Tipo: Texto descriptivo.
Ejemplo: `Monetizable`.
### Montos
Montos en bolivianos corrientes que hacen al presupuesto y ejecución de cada ingreso. Incluye 2 montos de presupuesto: al inicio de la gestión y luego de modificaciones, y 2 montos de ejecución: devengado y percibido.
#### inicial
Monto presupuestado al inicio de la gestión, en bolivianos corrientes.
Tipo: Monto en bolivianos corrientes con dos decimales.
Ejemplo: `2014000`.
#### vigente
Monto presupuestado luego de modificaciones durante la gestión, en bolivianos corrientes.
Tipo: Monto en bolivianos corrientes con dos decimales.
Ejemplo: `5653320`.
#### devengado
Monto devengado para la ejecución, en bolivianos corrientes.
Tipo: Monto en bolivianos corrientes con dos decimales.
Ejemplo: `5637823.65`.
#### percibido
Monto percibido, en bolivianos corrientes.
Tipo: Monto en bolivianos corrientes con dos decimales.
Ejemplo: `5637823.65`.
# Origen y calidad
La calidad de estas bases depende principalmente de los sistemas dónde se registran y procesan. En la historia reciente del presupuesto la información pudo ser registrada de manera central o descentralizada. Un registro central se almacena directamente en un servidor controlado por el Ministerio de Economía, que tiende a facilitar la coherencia, completitud y resguardo de la base. Mientras que un registro descentralizado se almacena en una computadora de la entidad responsable y sólo es comunicado periódicamente al Ministerio. La probabilidad de errores en el llenado, envío, validación y resguardo histórico de registros descentralizados es mucho mayor. Prácticamente toda la dificultad de reconstruir bases históricas recae en cómo integrar registros descentralizados ruidosos con registros centralizados en buen estado. Estos registros son particularmente comunes antes de 2016, cuando el SIGEP, un sistema centralizado, es implementado en entidades del nivel central del Estado.
\ No newline at end of file
<script>
import { scaleLinear, scaleBand } from 'd3-scale';
let {
data = [],
hoveredYear = $bindable(null),
height = 220,
fill = false,
marginTop = 20,
marginRight = 10,
marginBottom = 30,
marginLeft = 50
} = $props();
// Medir altura real del contenedor cuando fill=true
let wrapperEl = $state(null);
let measuredHeight = $state(height);
$effect(() => {
if (fill && wrapperEl) {
const updateHeight = () => {
const h = wrapperEl.clientHeight;
if (h > 0) measuredHeight = h;
};
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(wrapperEl);
return () => observer.disconnect();
} else {
measuredHeight = height;
}
});
// Dimensiones del área del gráfico
let effectiveHeight = $derived(fill ? measuredHeight : height);
let chartHeight = $derived(effectiveHeight - marginTop - marginBottom);
// Escalas D3
let xScale = $derived(
scaleBand()
.domain(data.map(d => d.año))
.range([0, 100]) // porcentaje
.padding(0.2)
);
// Para CSS bottom positioning: 0 → 0%, max → 100%
let yScale = $derived(
scaleLinear()
.domain([0, Math.max(...data.map(d => d.perCapita))])
.range([0, chartHeight])
.nice()
);
// Ticks para el eje Y
let yTicks = $derived(yScale.ticks(3));
// Índices para mostrar en eje X
let midIndex = $derived(Math.floor(data.length / 2));
// Formato para valores del eje Y
function formatValue(v) {
if (v >= 1e6) return (v / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
if (v >= 1e3) return Math.round(v / 1e3).toLocaleString('es-BO') + ' K';
return v.toLocaleString('es-BO');
}
</script>
<div class="chart-wrapper" bind:this={wrapperEl} style="height: {fill ? '100%' : height + 'px'}">
<!-- Contenedor principal con márgenes -->
<div class="chart-inner" style="top: {marginTop}px; bottom: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px">
<!-- Y-axis labels (fuera del área de barras, a la izquierda) -->
{#each yTicks as tick}
{@const pct = (yScale(tick) / chartHeight) * 100}
<span class="y-label" style="bottom: {pct}%; left: -{marginLeft}px">
Bs {formatValue(tick)}
</span>
{/each}
<!-- Grid lines -->
{#each yTicks as tick}
{@const pct = (yScale(tick) / chartHeight) * 100}
<div class="grid-line" style="bottom: {pct}%"></div>
{/each}
<!-- Barras -->
<div class="bars" onmouseleave={() => hoveredYear = null} role="group">
{#each data as d}
{@const barPct = (yScale(d.perCapita) / chartHeight) * 100}
<div
class="bar-container"
onmouseenter={() => hoveredYear = d.año}
role="button"
tabindex="0"
>
<div
class="bar"
class:hovered={hoveredYear === d.año}
style="height: {barPct}%"
></div>
</div>
{/each}
</div>
</div>
<!-- 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}
>
{d.año}
</span>
{/each}
</div>
</div>
<style>
.chart-wrapper {
position: relative;
width: 100%;
transition: height 0.3s ease;
}
.chart-inner {
position: absolute;
}
.y-label {
position: absolute;
width: 45px;
text-align: right;
transform: translateY(50%);
font-family: 'Qanelas', var(--font-sans);
font-size: 0.6875rem;
color: var(--theme-texto);
white-space: nowrap;
}
.grid-line {
position: absolute;
left: 0;
right: 0;
border-top: 0.5px dotted var(--theme-texto);
opacity: 0.15;
pointer-events: none;
}
.bars {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: flex-end;
gap: 2px;
}
.bar-container {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
cursor: default;
}
.bar {
width: 100%;
background: #c4897d;
border-radius: 4px 4px 0 0;
transition: opacity 0.1s ease;
min-height: 2px;
}
.bars:hover .bar {
opacity: 0.35;
}
.bars:hover .bar.hovered {
opacity: 1;
}
.x-axis {
position: absolute;
bottom: 0;
display: flex;
align-items: center;
}
.x-label {
flex: 1;
text-align: center;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0;
transition: opacity 0.2s ease;
}
.x-label.visible {
opacity: 1;
}
</style>
<script>
let {
entidades = [],
selectedEntity = $bindable(null),
nEntidades = 0
} = $props();
let dropdownOpen = $state(false);
let searchQuery = $state('');
let entityLimit = $state(30);
let filteredEntities = $derived.by(() => {
if (!searchQuery) return entidades.slice(0, entityLimit);
const q = searchQuery.toLowerCase();
return entidades
.filter(e =>
e.entidad_desc?.toLowerCase().includes(q) ||
e.entidad?.toString().includes(q)
)
.slice(0, entityLimit);
});
function selectEntity(entity) {
selectedEntity = entity;
dropdownOpen = false;
searchQuery = '';
}
function clearEntity() {
selectedEntity = null;
dropdownOpen = false;
searchQuery = '';
}
function handleScroll(e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop + clientHeight >= scrollHeight - 50) {
entityLimit += 20;
}
}
</script>
<div class="entity-selector">
<button class="entity-btn" onclick={() => dropdownOpen = !dropdownOpen}>
<span class="entity-label">
{#if selectedEntity}
{selectedEntity.entidad_desc}
{:else}
Todo el sector público
{/if}
</span>
<svg class="chevron" class:open={dropdownOpen} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if dropdownOpen}
<div class="entity-dropdown">
<div class="dropdown-search">
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar entidad..."
/>
</div>
<button
class="dropdown-item"
class:active={!selectedEntity}
onclick={clearEntity}
>
Todo el sector público
<span class="item-count">({nEntidades})</span>
</button>
<div class="dropdown-list" onscroll={handleScroll}>
{#each filteredEntities as entity}
<button
class="dropdown-item"
class:active={selectedEntity?.entidad === entity.entidad}
onclick={() => selectEntity(entity)}
>
<span class="item-code">{entity.entidad}</span>
<span class="item-name">{entity.entidad_desc}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
<style>
.entity-selector {
position: relative;
flex: 1;
min-width: 0;
}
.entity-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0;
background: transparent;
border: none;
border-bottom: 1px dotted #333333;
color: var(--theme-titulo);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s;
}
.entity-btn:hover {
border-bottom: 1px dotted #666666;
}
.entity-label {
text-align: left;
}
.chevron {
color: var(--theme-texto);
opacity: 0.5;
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.open {
transform: rotate(180deg);
}
.entity-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 280px;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
}
.dropdown-search {
padding: 0.5rem;
border-bottom: 1px solid var(--theme-borde);
}
.dropdown-search input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: var(--theme-body);
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
}
.dropdown-search input:focus {
border-color: #6B9FD4;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover {
background: var(--theme-surface-hover);
}
.dropdown-item.active {
background: rgba(107, 159, 212, 0.1);
color: #6B9FD4;
}
.item-count {
margin-left: auto;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
}
.item-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
min-width: 2rem;
}
.item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-list {
max-height: 240px;
overflow-y: auto;
}
:global(html.dark) .dropdown-item.active {
background: rgba(201, 167, 81, 0.1);
color: var(--theme-accent);
}
</style>
<script>
import { goto } from '$app/navigation';
import {
query,
results,
isLoading as searchLoading,
indexLoaded,
selectedIndex,
performSearch,
clearSearch,
navigateResults
} from '$lib/stores/searchStore';
let searchInputRef = $state(null);
let searchVal = $state('');
let searchFocused = $state(false);
let debounceTimer;
// Filtrar solo objeto_gasto
let filteredSearchResults = $derived($results.filter(r => r.tipo === 'objeto_gasto'));
function handleSearchInput(e) {
const val = e.target.value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (val.length >= 2) {
performSearch(val);
} else {
clearSearch();
}
}, 200);
}
function handleSearchKeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
navigateResults(e.key === 'ArrowDown' ? 1 : -1, filteredSearchResults.length);
} else if (e.key === 'Enter' && filteredSearchResults.length > 0) {
e.preventDefault();
const selected = filteredSearchResults[$selectedIndex];
if (selected) handleSearchSelect(selected);
} else if (e.key === 'Escape') {
searchVal = '';
clearSearch();
searchInputRef?.blur();
}
}
function handleSearchSelect(result) {
goto(`/objeto/${result.codigo}`);
searchVal = '';
clearSearch();
searchFocused = false;
}
function handleSearchBlur() {
setTimeout(() => {
searchFocused = false;
}, 150);
}
function handleClear() {
clearSearch();
searchVal = '';
}
</script>
<div class="objeto-search" class:focused={searchFocused}>
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
<input
type="text"
bind:this={searchInputRef}
bind:value={searchVal}
oninput={handleSearchInput}
onkeydown={handleSearchKeydown}
onfocus={() => searchFocused = true}
onblur={handleSearchBlur}
placeholder="Buscar objeto..."
disabled={!$indexLoaded}
/>
{#if searchVal}
<button class="search-clear" onclick={handleClear}>
<svg width="12" height="12" 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>
{/if}
{#if searchFocused && searchVal.length >= 2}
<div class="search-dropdown">
{#if $searchLoading}
<div class="search-msg">Buscando...</div>
{:else if filteredSearchResults.length === 0}
<div class="search-msg">Sin resultados</div>
{:else}
{#each filteredSearchResults as result, i}
<button
class="search-item"
class:selected={$selectedIndex === i}
onclick={() => handleSearchSelect(result)}
onmouseenter={() => selectedIndex.set(i)}
>
<span class="item-code">{result.codigo}</span>
<span class="item-name">{result.nombre}</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
<style>
.objeto-search {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
background: #f5f5f5;
border: none;
border-radius: 8px;
padding: 0.625rem 1rem;
transition: box-shadow 0.2s;
width: 100%;
}
.objeto-search.focused {
border-color: #6B9FD4;
box-shadow: 0 0 0 3px rgba(107, 159, 212, 0.15);
}
.search-icon {
color: var(--theme-texto);
opacity: 0.5;
flex-shrink: 0;
}
input {
flex: 1;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
min-width: 120px;
}
input::placeholder {
color: var(--theme-texto);
opacity: 0.5;
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: 4px;
border: none;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
transition: background 0.2s;
}
.search-clear:hover {
background: var(--theme-borde);
}
.search-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-height: 280px;
overflow-y: auto;
z-index: 100;
}
.search-msg {
padding: 0.75rem 1rem;
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.7;
}
.search-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 1rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.search-item:hover,
.search-item.selected {
background: var(--theme-surface-hover);
}
.item-code {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: var(--theme-texto);
min-width: 3.5rem;
}
.item-name {
font-size: 0.8125rem;
color: var(--theme-titulo);
}
</style>
<script>
let { vista = $bindable('agregado') } = $props();
function toggle() {
vista = vista === 'agregado' ? 'comparar' : 'agregado';
}
</script>
<button class="vista-btn" onclick={toggle}>
{#if vista === 'agregado'}
Comparar entre entidades
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
{:else}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Ver todo
{/if}
</button>
<style>
.vista-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.75rem;
font-weight: 500;
padding: 0;
border: none;
background: transparent;
color: #666666;
cursor: pointer;
transition: color 0.15s ease;
white-space: nowrap;
text-decoration: underline dotted;
text-underline-offset: 3px;
text-decoration-color: #666666;
}
.vista-btn svg {
flex-shrink: 0;
opacity: 0.7;
}
.vista-btn:hover {
color: #999999;
text-decoration-color: #666666;
}
.vista-btn:hover svg {
opacity: 1;
}
.vista-btn:active {
color: #555555;
}
</style>
......@@ -60,13 +60,25 @@
</div>
{/if}
<div class="min-h-screen" style="background-color: var(--theme-body);">
<main>
<div class="layout-wrapper">
<main class="layout-main">
{@render children()}
</main>
</div>
<style>
.layout-wrapper {
min-height: 100vh;
width: 100%;
background-color: var(--theme-body);
}
.layout-main {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.nav-loader {
position: fixed;
top: 0;
......
......@@ -37,6 +37,7 @@
let gearMenuOpen = false;
let searchInput;
let debounceTimer;
let autoFocused = false;
// Search filters - which types to include
let searchFilters = {
......@@ -68,6 +69,14 @@
warmupSupabase();
});
// Auto-focus en el buscador cuando el índice esté listo (solo una vez)
$: if ($indexLoaded && searchInput && !autoFocused) {
autoFocused = true;
setTimeout(() => {
searchInput?.focus();
}, 50);
}
function toggleMobileSettings() {
mobileSettingsOpen = !mobileSettingsOpen;
}
......@@ -124,8 +133,12 @@
break;
case 'Enter':
e.preventDefault();
// Si hay un resultado seleccionado, navegar a él
if ($selectedIndex >= 0 && filteredResults[$selectedIndex]) {
handleSelect(filteredResults[$selectedIndex]);
} else if (filteredResults.length > 0) {
// Si no hay selección pero hay resultados, ir al primero
handleSelect(filteredResults[0]);
}
break;
case 'Escape':
......@@ -323,11 +336,11 @@
on:blur={handleBlur}
disabled={!$indexLoaded}
/>
{#if !searchVal && !searchFocused}
{#if !searchVal}
<div class="search-placeholder">
<span class="blink-cursor" style="background:{accent}"></span>
<span class="placeholder-text placeholder-desktop">{$indexLoaded ? placeholder : 'Cargando...'}</span>
<span class="placeholder-text placeholder-mobile">{$indexLoaded ? (isD ? 'MEFP, Min. Salud, GAM...' : 'BOA, Publicidad, IDH...') : 'Cargando...'}</span>
<span class="placeholder-text placeholder-desktop">{placeholder}</span>
<span class="placeholder-text placeholder-mobile">{isD ? 'MEFP, Min. Salud, GAM...' : 'BOA, Publicidad, IDH...'}</span>
</div>
{/if}
{#if searchVal && !$isLoading}
......
......@@ -16,19 +16,6 @@
let highlightedItem = $state(null);
let sidebarOpen = $state(false);
// Modo embed (cuando está dentro de un iframe en /objeto/[codigo])
let isEmbed = $derived($page.url.searchParams.get('embed') === 'true');
let initialObjeto = $derived($page.url.searchParams.get('objeto'));
// Función para navegar a un objeto (en modo embed envía mensaje al padre)
function navigateToObjeto(codigo) {
if (isEmbed && window.parent !== window) {
window.parent.postMessage({ type: 'navigate-objeto', codigo }, '*');
} else {
goto(`/objeto/${codigo}`);
}
}
// Modo de visualización desde URL
let viewMode = $derived($page.url.searchParams.get('modo') || 'lista');
......@@ -1459,7 +1446,6 @@
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver detalle"
onclick={(e) => { if (isEmbed) { e.preventDefault(); navigateToObjeto(selectedGrupo.objeto); }}}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
......@@ -1510,7 +1496,6 @@
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
onclick={(e) => { if (isEmbed) { e.preventDefault(); navigateToObjeto(subgrupo.objeto); }}}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
......@@ -1557,7 +1542,6 @@
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
onclick={(e) => { if (isEmbed) { e.preventDefault(); navigateToObjeto(partida.objeto); }}}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
......@@ -1600,7 +1584,6 @@
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
onclick={(e) => { if (isEmbed) { e.preventDefault(); navigateToObjeto(subpartida.objeto); }}}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
......
......@@ -2,9 +2,14 @@
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { supabase } from '$lib/supabase';
import { goto } from '$app/navigation';
import { supabase } from '$lib/supabase';
import Spinner from '$lib/components/ui/Spinner.svelte';
import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
import BarChart from '$lib/components/objeto/BarChart.svelte';
import { initIndex } from '$lib/stores/searchStore';
// Datos del servidor (reactivos)
let { data } = $props();
......@@ -23,9 +28,12 @@
let loading = $state(true);
let hoveredYear = $state(null);
let showInfo = $state(false);
let showDefinicionesModal = $state(false);
let showMobileSidebar = $state(false);
let vista = $state('agregado'); // 'agregado' | 'comparar'
let isDark = $state(true);
// Parsear descripciones del objeto
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
......@@ -82,8 +90,41 @@
return labels[nivel] || nivel;
}
function getNivelPlural(nivel) {
const labels = { grupo: 'grupos', subgrupo: 'subgrupos', partida: 'partidas', subpartida: 'subpartidas' };
return labels[nivel] || nivel;
}
function getNivelGastado(nivel) {
const gastado = { grupo: 'gastado', subgrupo: 'gastado', partida: 'gastada', subpartida: 'gastada' };
return gastado[nivel] || 'gastado';
}
function getNivelSingular(nivel) {
const labels = { grupo: 'grupo', subgrupo: 'subgrupo', partida: 'partida', subpartida: 'subpartida' };
return labels[nivel] || nivel;
}
function getNivelArticulo(nivel) {
const articulos = { grupo: 'el', subgrupo: 'el', partida: 'la', subpartida: 'la' };
return articulos[nivel] || 'el';
}
function getNivelArticuloContracto(nivel) {
const articulos = { grupo: 'del', subgrupo: 'del', partida: 'de la', subpartida: 'de la' };
return articulos[nivel] || 'del';
}
function getHijosNivelLabel(nivel) {
const labels = { grupo: 'subgrupos', subgrupo: 'partidas', partida: 'subpartidas' };
return labels[nivel] || '';
}
let descripciones = $derived(parseDescripciones(objetoData.descripciones));
// Texto narrativo para el nivel
let nivelNarrativo = $derived(`${getNivelArticulo(objetoData.nivel)} ${getNivelLabel(objetoData.nivel)} de Gasto`);
// Info del objeto (reactivo)
let objeto = $derived({
codigo: objetoData.objeto,
......@@ -153,31 +194,6 @@
}
// ══════════════════════════════════════════════════════════════
// SPLIT VIEW - CLASIFICADOR LADO A LADO
// ══════════════════════════════════════════════════════════════
let splitOpen = $state(false);
let splitWidth = $state(45); // porcentaje del ancho para el clasificador
let isResizing = $state(false);
function startResize(e) {
isResizing = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
function handleResize(e) {
if (!isResizing) return;
const newWidth = (e.clientX / window.innerWidth) * 100;
splitWidth = Math.max(25, Math.min(65, newWidth)); // entre 25% y 65%
}
function stopResize() {
isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
// ══════════════════════════════════════════════════════════════
// DATOS DE SUPABASE
// ══════════════════════════════════════════════════════════════
let datosAnuales = $state([]); // Datos por año
......@@ -344,21 +360,21 @@
);
let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
let displayMontoLabel = $derived(currentData ? `gasto en ${currentData.año}` : `gasto ${primerAño}-${ultimoAño}`);
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 persona en ${currentData.año}` : 'por persona (promedio)');
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))).toFixed(1)
: totalPorcentaje
);
let displayPorcentajeLabel = $derived(currentData ? `del gasto ${currentData.año}` : 'del gasto total');
let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total');
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${displayRankingValue} de ${rankingDenominador}`);
let displayRankingLabel = $derived(
selectedEntity
? (currentData ? `entre las que más gastan en ${currentData.año}` : 'entre las que más gastan')
: (currentData ? `ranking en ${currentData.año}` : 'ranking entre grupos')
: (currentData ? `${getNivelPlural(objetoData.nivel)} más ${getNivelGastado(objetoData.nivel)} en ${currentData.año}` : `${getNivelPlural(objetoData.nivel)} más ${getNivelGastado(objetoData.nivel)}`)
);
// Top entidades reactivo según hover
......@@ -642,6 +658,9 @@
const mountStart = performance.now();
setTimeout(() => { mounted = true; }, 50);
// Inicializar índice de búsqueda
initIndex();
// Leer estado del tema
isDark = document.documentElement.classList.contains('dark');
......@@ -660,7 +679,7 @@
console.log(`[CLIENT] Promise.all: ${(performance.now() - loadStart).toFixed(0)}ms`);
console.log(`[CLIENT] onMount total: ${(performance.now() - mountStart).toFixed(0)}ms`);
// Cerrar dropdown al hacer click fuera
// Cerrar dropdowns al hacer click fuera
const handleClickOutside = (e) => {
if (entityDropdownOpen && !e.target.closest('.entity-dropdown-container')) {
entityDropdownOpen = false;
......@@ -675,24 +694,9 @@
};
document.addEventListener('click', handleClickOutside);
// Resize handlers para split view
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
// Escuchar mensajes del iframe (clasificador en modo embed)
const handleIframeMessage = (e) => {
if (e.data?.type === 'navigate-objeto' && e.data?.codigo) {
goto(`/objeto/${e.data.codigo}`);
}
};
window.addEventListener('message', handleIframeMessage);
return () => {
observer.disconnect();
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
window.removeEventListener('message', handleIframeMessage);
};
});
......@@ -714,20 +718,29 @@
}
});
// Recargar datos cuando cambia la entidad seleccionada
let previousEntity = selectedEntity;
$effect(() => {
if (mounted && selectedEntity !== previousEntity) {
previousEntity = selectedEntity;
loadData();
}
});
// ══════════════════════════════════════════════════════════════
// HELPERS
// ══════════════════════════════════════════════════════════════
function formatMonto(m) {
if (m >= 1e9) return (m / 1e9).toFixed(1) + 'MM';
if (m >= 1e6) return (m / 1e6).toFixed(1) + 'M';
if (m >= 1e3) return Math.round(m / 1e3) + 'K';
return m?.toLocaleString() || '0';
if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + ' MM';
if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + ' M';
if (m >= 1e3) return Math.round(m / 1e3).toLocaleString('es-BO') + ' K';
return m?.toLocaleString('es-BO') || '0';
}
function formatMontoLargo(m) {
if (m >= 1e9) return Math.round(m / 1e9).toLocaleString('es-BO') + ' mil Mill.';
if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + ' Mill.';
return m?.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0';
if (m >= 1e9) return Math.round(m / 1e9).toLocaleString('es-BO') + ' mil mill. de Bs.';
if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + ' mill. de Bs.';
return m?.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' Bs.' || '0';
}
function formatPerCapita(n) {
......@@ -744,286 +757,246 @@
<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>
<!-- Contenedor split view -->
<div class="split-container" class:split-active={splitOpen}>
<!-- Panel izquierdo: Clasificador -->
{#if splitOpen}
<div class="split-left" style="width: {splitWidth}%">
<div class="split-header">
<span>Clasificador por Objeto</span>
<button class="split-close" onclick={() => splitOpen = false}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<iframe
src="/clasificadores/objeto-gasto?embed=true&objeto={objetoCodigo}"
title="Clasificador"
class="split-iframe"
></iframe>
</div>
<!-- Resizer -->
<div
class="split-resizer"
onmousedown={startResize}
role="separator"
></div>
{/if}
<!-- Panel derecho: Contenido principal -->
<div class="split-right" class:mounted>
<!-- Header compacto -->
<header>
<nav class="breadcrumb">
<a href="/clasificadores/objeto-gasto">← Objeto del Gasto</a>
{#each padres as padre}
<span class="breadcrumb-sep">/</span>
<a href="/objeto/{padre.objeto}" class="breadcrumb-link">{padre.desc_objeto}</a>
{/each}
<div class="page" class:mounted>
<!-- Link a clasificadores -->
<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="title-row">
<div class="title-main">
<span class="codigo">{objeto.codigo}</span>
<h1>
{objeto.nombre}
<button class="help-icon" onclick={() => showInfo = !showInfo} class:active={showInfo}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M9.5 9a3 3 0 1 1 3.5 2.95c-.5.17-1 .62-1 1.3V15"/>
<circle cx="12" cy="18" r="0.5" fill="currentColor"/>
</svg>
</button>
</h1>
</div>
</div>
<!-- DASHBOARD LAYOUT -->
{#if vista === 'agregado'}
<div class="dashboard-layout">
<!-- CONTEXTO IZQUIERDO -->
<aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
<!-- Botón móvil para abrir/cerrar -->
<button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="21" x2="4" y2="14"/>
<line x1="4" y1="10" x2="4" y2="3"/>
<line x1="12" y1="21" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12" y2="3"/>
<line x1="20" y1="21" x2="20" y2="16"/>
<line x1="20" y1="12" x2="20" y2="3"/>
<circle cx="4" cy="12" r="2"/>
<circle cx="12" cy="10" r="2"/>
<circle cx="20" cy="14" r="2"/>
</svg>
<span class="toggle-label">{showMobileSidebar ? 'Ocultar' : 'Herramientas'}</span>
</button>
<div class="meta">
<span class="badge">{objeto.nivel}</span>
<span class="years">{formatYearsAsRanges(objeto.años)}</span>
</div>
</header>
<!-- Panel de info (colapsable) -->
{#if showInfo}
<div class="info-panel">
<div class="info-content">
<h4>Definición vigente</h4>
<p>{objeto.descripcion}</p>
{#if objeto.variaciones.length > 1}
<h4>Variaciones históricas</h4>
{#each objeto.variaciones as v}
<div class="variacion">
<span class="variacion-rango">{formatYearsAsRanges(v.rango)}</span>
<p>{v.texto}</p>
</div>
{/each}
{/if}
<div class="context-card">
<!-- Título principal -->
<h2 class="context-main-title">Herramientas</h2>
{#if hijos.length > 0}
<h4>
{#if objetoNivel === 'grupo'}
Subgrupos ({hijos.length})
{:else if objetoNivel === 'subgrupo'}
Partidas ({hijos.length})
{:else if objetoNivel === 'partida'}
Subpartidas ({hijos.length})
{/if}
</h4>
<div class="hijos-list">
{#each hijos as hijo}
<a href="/objeto/{hijo.objeto}" class="hijo-link">
<span class="hijo-codigo">{hijo.objeto}</span>
<span class="hijo-nombre">{hijo.desc_objeto}</span>
</a>
{/each}
<!-- Buscador -->
<div class="context-section">
<h3 class="context-subtitle">Buscar</h3>
<p class="context-hint">Buscar otro grupo, subgrupo, partida o subpartida</p>
<ObjetoSearch />
</div>
{/if}
</div>
</div>
{/if}
<!-- Dashboard principal -->
<main>
<!-- Toggle de vista -->
<div class="vista-toggle">
<button
class="toggle-btn"
class:active={vista === 'agregado'}
onclick={() => vista = 'agregado'}
>
Agregado
</button>
<button
class="toggle-btn"
class:active={vista === 'comparar'}
onclick={() => vista = 'comparar'}
>
Comparar
</button>
</div>
<!-- Selector de entidad (solo en vista agregado) -->
{#if vista === 'agregado'}
<div class="entity-selector entity-dropdown-container">
<span class="selector-label">Viendo</span>
<div class="relative">
<button
class="selector-btn"
onclick={() => entityDropdownOpen = !entityDropdownOpen}
>
<span class="truncate">
{#if selectedEntity}
{selectedEntity.entidad_desc?.slice(0, 30)}{selectedEntity.entidad_desc?.length > 30 ? '...' : ''}
{:else}
Todo el sector público
{/if}
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if entityDropdownOpen}
<div class="dropdown-panel">
<div class="dropdown-search">
<input
type="text"
bind:value={entitySearchQuery}
placeholder="Buscar entidad..."
class="search-input"
/>
</div>
<button
class="dropdown-item"
class:active={!selectedEntity}
onclick={() => clearEntity()}
>
<span class="font-medium">Todo el sector público</span>
<span class="text-xs opacity-60 ml-2">({nEntidades} entidades)</span>
</button>
<div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'main')}>
{#each filteredEntities as entity}
<button
class="dropdown-item"
class:active={selectedEntity?.entidad === entity.entidad}
onclick={() => selectEntity(entity)}
>
<span class="entity-code">{entity.entidad}</span>
<span class="truncate">{entity.entidad_desc}</span>
</button>
<!-- Separador -->
<div class="context-separator"></div>
<!-- Contexto: Árbol de jerarquía -->
{#if padres.length > 0 || hijos.length > 0}
<div class="context-section">
<h3 class="context-subtitle">Navegación</h3>
<p class="context-hint">Partidas relacionadas. <span class="desktop-hint">Haz click</span><span class="mobile-hint">Toca</span> 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}
{#if entityLimit < entidades.length}
<div class="text-center py-2 text-xs opacity-60">
Scroll para ver más ({entidades.length - entityLimit} restantes)
<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>
{/if}
</div>
<span class="interaction-hint desktop-hint">Pasa el cursor sobre las barras para ver valores por año</span>
<span class="interaction-hint mobile-hint">Toca las barras para ver valores por año</span>
</div>
{/if}
{#if vista === 'agregado'}
<!-- VISTA AGREGADO -->
<!-- KPIs arriba -->
<div class="kpis">
<div class="kpi">
<span class="kpi-value">Bs {formatMontoLargo(Math.round($twMonto))}</span>
<span class="kpi-label">{displayMontoLabel}</span>
</div>
<!-- Separador -->
<div class="context-separator"></div>
<!-- Contexto: Vigencia temporal -->
<div class="context-section">
<h3 class="context-subtitle">Comparabilidad</h3>
<div class="vigencia-compact">
{#if gastoPerCapita.length === 21 && primerAño === 2005 && ultimoAño === 2025}
<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">{primerAño} – {ultimoAño}</span>
</div>
{:else}
<div class="vigencia-status-icon vigencia-partial">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<div class="vigencia-text">
<span class="vigencia-main">Comparable en {gastoPerCapita.length} años</span>
<span class="vigencia-sub">{primerAño} – {ultimoAño}</span>
</div>
{/if}
</div>
</div>
<div class="kpi">
<span class="kpi-value">Bs {formatPerCapita($twPerCapita)}</span>
<span class="kpi-label">{displayPerCapitaLabel}</span>
</div>
<!-- Separador -->
<div class="context-separator"></div>
<div class="kpi">
<span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="kpi-label">{displayPorcentajeLabel}</span>
<!-- Contexto: Definición oficial -->
<div class="context-section">
<h3 class="context-subtitle">Definición</h3>
<p class="context-desc">Descripción oficial {getNivelArticuloContracto(objetoData.nivel)} {objetoData.nivel} según el clasificador presupuestario.</p>
<div class="definicion-compact">
{#if objeto.variaciones.length === 1}
<span class="def-status">Sin cambios en el período</span>
{:else}
<span class="def-status def-changed">{objeto.variaciones.length} versiones en el período</span>
{/if}
<button class="def-btn" onclick={() => showDefinicionesModal = true}>
Ver
</button>
</div>
</div>
</div>
</aside>
<!-- CONTENIDO PRINCIPAL -->
<main class="dashboard-main" class:expanded={selectedEntity}>
<!-- Gráfico principal con KPIs integrados -->
<div class="chart-section chart-protagonista" class:expanded={selectedEntity}>
<!-- Header: pills + botón -->
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">{objeto.nivel}</span>
<span class="pill pill-codigo">{objeto.codigo}</span>
</div>
<VistaToggle bind:vista />
</div>
<div class="kpi">
<span class="kpi-value">{displayRanking}</span>
<span class="kpi-label">{displayRankingLabel}</span>
</div>
</div>
<!-- Título principal -->
<h2 class="main-title-name">{objeto.nombre}</h2>
<!-- Gráfico de barras (per cápita) - protagonista al medio -->
<div class="chart-section chart-protagonista">
<div class="chart-header">
<span class="chart-title">Gasto por persona</span>
<span class="chart-period">{displayPeriodo}</span>
</div>
<!-- Selector de entidad -->
<div class="chart-controls">
<EntitySelector {entidades} bind:selectedEntity {nEntidades} />
</div>
<div class="chart-wrapper">
<!-- Y-axis labels -->
<div class="y-axis">
<span class="y-label">Bs {formatMonto(maxPerCapita)}</span>
<span class="y-label">Bs {formatMonto(Math.round(maxPerCapita / 2))}</span>
<span class="y-label">Bs 0</span>
<!-- Título del gráfico -->
<div class="chart-header">
<span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span>
</div>
<BarChart data={gastoPerCapita} bind:hoveredYear height={280} fill={!!selectedEntity} />
<div
class="chart"
onmouseleave={() => hoveredYear = null}
role="group"
>
{#each gastoPerCapita as g}
<div
class="bar-container"
onmouseenter={() => hoveredYear = g.año}
role="button"
tabindex="0"
>
<div
class="bar"
class:hovered={hoveredYear === g.año}
style="height: {(g.perCapita / maxPerCapita) * 100}%"
></div>
<span class="bar-label" class:visible={hoveredYear === g.año || g.año % 5 === 0}>
{g.año.toString().slice(-2)}
</span>
</div>
{/each}
<!-- KPIs abajo -->
<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>
</div>
<!-- Top entidades (solo visible cuando es "Todo el Estado") -->
{#if !selectedEntity}
<div class="top-section">
<div class="top-header">
<h3>Mayores ejecutores</h3>
<span class="top-context">{totalEntidades} entidades · {displayTopLabel}</span>
<!-- Ranking simplificado -->
{#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 {objeto.nombre.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>
<div class="top-list">
{#each displayTopEntidades as ent, i (ent.nombre)}
<div class="top-item">
<span class="top-rank">#{i + 1}</span>
<div class="top-info">
<span class="top-name">{ent.nombre}</span>
<div class="top-bar-bg">
<div class="top-bar" style="width: {((i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3) / $twTop1) * 100}%"></div>
</div>
<!-- Modal de definiciones (fuera del layout) -->
{#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 objeto.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>
<span class="top-pct">{(i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3).toFixed(1)}%</span>
<p class="def-text">{v.texto}</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
{:else}
{:else}
<!-- VISTA COMPARAR (mantiene layout original) -->
<div class="toolbar">
<ObjetoSearch />
<VistaToggle bind:vista />
</div>
<main>
<!-- VISTA COMPARAR -->
<!-- Selectores de entidades con búsqueda -->
......@@ -1139,7 +1112,7 @@
<!-- Gráfico comparativo -->
<div class="chart-section">
<div class="chart-header">
<span class="chart-title">Gasto por persona</span>
<span class="chart-title">Bs/habitante</span>
<span class="chart-period">{displayPeriodoComparar}</span>
</div>
......@@ -1221,440 +1194,887 @@
</div>
{/if}
{/if}
</main>
</div><!-- end split-right -->
<!-- Botón para abrir split view -->
<button
class="split-toggle"
onclick={() => splitOpen = !splitOpen}
title={splitOpen ? "Cerrar clasificador" : "Abrir clasificador"}
class:active={splitOpen}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="18" rx="1"/>
<rect x="14" y="3" width="7" height="18" rx="1"/>
</svg>
<span class="split-toggle-label">{splitOpen ? 'Cerrar' : 'Clasificador'}</span>
</button>
</div><!-- end split-container -->
</main>
{/if}
</div>
<style>
/* Split container */
.split-container {
display: flex;
/* ═══════════════════════════════════════════════════════════════
PAGE & HEADER - Modern Compact Design
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
width: 100%;
max-width: 100vw;
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; }
.split-left {
flex-shrink: 0;
/* Fondo claro específico para objeto */
:global(html:not(.dark)) .page {
background: #f5f5f7;
}
/* ═══════════════════════════════════════════════════════════════
DASHBOARD LAYOUT - Sidebar + Main Content
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: 320px minmax(auto, 900px);
gap: 16px;
padding: 1rem 2rem 2rem;
max-width: 1600px;
margin: 0 auto;
justify-content: center;
}
/* Contexto */
.dashboard-context {
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
border-right: 1px solid var(--theme-borde);
min-height: 0;
}
/* Mobile sidebar toggle - oculto en desktop */
.mobile-sidebar-toggle {
display: none;
}
.split-right {
.context-card {
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
min-width: 0;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s ease;
min-height: 0;
}
.split-right.mounted { opacity: 1; }
/* Header */
header {
padding: 1rem 2rem;
border-bottom: 1px solid var(--theme-borde);
.context-main-title {
font-size: 1.375rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
.context-subtitle {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
}
.breadcrumb a {
font-family: 'DM Mono', monospace;
.context-desc {
font-size: 0.75rem;
color: var(--theme-texto);
text-decoration: none;
transition: color 0.2s;
color: #666666;
margin: 0.375rem 0 0.5rem;
line-height: 1.4;
}
.breadcrumb a:hover { color: var(--theme-titulo); }
.breadcrumb-sep {
color: var(--theme-borde);
.context-hint {
font-size: 0.75rem;
color: #666666;
margin: 0;
line-height: 1.4;
}
.breadcrumb-link {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.context-label {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.75rem;
font-weight: 600;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.03em;
margin: 0;
}
.title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-top: 0.5rem;
gap: 1rem;
.desktop-hint {
display: inline;
}
.title-main {
display: flex;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
.mobile-hint {
display: none;
}
.codigo {
font-family: 'DM Mono', monospace;
font-size: 1.5rem;
color: var(--theme-texto);
font-weight: 400;
@media (max-width: 768px) {
.desktop-hint {
display: none;
}
.mobile-hint {
display: inline;
}
}
h1 {
font-family: var(--font-display), Georgia, serif;
font-size: 1.5rem;
font-weight: 400;
color: var(--theme-titulo);
margin: 0;
display: inline-flex;
align-items: center;
.context-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.help-icon {
background: transparent;
border: none;
padding: 0;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.help-icon:hover, .help-icon.active {
color: var(--theme-accent);
.context-separator {
border-top: 1px solid var(--theme-borde);
margin: 0.25rem 0;
}
.meta {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
.sidebar-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 1rem;
}
.badge {
.sidebar-card-header {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
font-size: 0.5625rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.25rem 0.5rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 4px;
color: var(--theme-texto);
opacity: 0.6;
margin-bottom: 0.75rem;
text-transform: uppercase;
}
.years {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: var(--theme-texto);
.sidebar-separator {
height: 1px;
background: var(--theme-borde);
margin: 0.5rem 0;
}
/* Split View - Header y controles */
.split-header {
.sidebar-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--theme-surface);
border-bottom: 1px solid var(--theme-borde);
font-size: 0.75rem;
font-weight: 600;
color: var(--theme-titulo);
flex-direction: column;
gap: 0.75rem;
}
.split-close {
padding: 0.25rem;
border: none;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
border-radius: 4px;
.sidebar-selectors {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.5rem;
}
.split-close:hover {
background: var(--theme-borde);
color: var(--theme-titulo);
/* Tree compacto para sidebar */
.dashboard-context .tree {
font-size: 0.75rem;
line-height: 1.6;
}
.split-iframe {
flex: 1;
width: 100%;
border: none;
background: var(--theme-body);
.dashboard-context .tree-node {
padding: 0.375rem 0.5rem;
margin-left: calc(var(--indent, 0) * 1rem);
border-left-width: 2px;
border-radius: 0 6px 6px 0;
}
.split-resizer {
width: 8px;
background: var(--theme-borde);
cursor: col-resize;
.dashboard-context .tree-name {
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
.dashboard-context .tree-code {
font-size: 0.6875rem;
min-width: 2.5rem;
}
.dashboard-context .tree-children {
margin-left: calc(var(--indent, 0) * 1rem);
}
.dashboard-context .tree-node-child {
padding-left: 0.75rem;
}
/* Vigencia compacta */
.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;
position: relative;
transition: background 0.2s;
}
.split-resizer::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 48px;
background: var(--theme-texto);
border-radius: 4px;
opacity: 0.3;
transition: opacity 0.2s, height 0.2s;
.vigencia-status-icon.vigencia-full {
background: rgba(74, 158, 107, 0.15);
color: #4A9E6B;
}
.split-resizer:hover {
background: var(--theme-accent);
.vigencia-status-icon.vigencia-partial {
background: rgba(212, 137, 61, 0.15);
color: #D4893D;
}
.split-resizer:hover::after {
opacity: 0.8;
height: 64px;
.vigencia-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
/* Botón para abrir/cerrar split view */
.split-toggle {
position: fixed;
left: 1rem;
bottom: 1.5rem;
display: none;
.vigencia-main {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-titulo);
}
.vigencia-sub {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
/* Definición compacta */
.definicion-compact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.625rem 1rem;
}
.def-status {
font-size: 0.8125rem;
color: #555555;
}
.def-status.def-changed {
color: #555555;
}
.def-btn {
font-size: 0.6875rem;
font-weight: 500;
color: #555555;
background: rgba(0, 0, 0, 0.06);
border: none;
padding: 0.25rem 0.625rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.def-btn:hover {
background: rgba(0, 0, 0, 0.1);
}
/* Main content */
.dashboard-main {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
width: 100%;
align-self: start;
padding-top: 0;
padding-bottom: 0;
transition: all 0.3s ease;
}
.dashboard-main.expanded {
align-self: stretch;
}
/* Header compacto */
.page-nav {
padding: 1rem 2rem;
}
.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); }
/* ═══════════════════════════════════════════════════════════════
TOOLBAR - Barra de acciones moderna
═══════════════════════════════════════════════════════════════ */
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 2rem;
background: var(--theme-surface);
border-bottom: 1px solid var(--theme-borde);
flex-wrap: wrap;
}
.entity-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 8px;
color: var(--theme-texto);
font-size: 0.75rem;
font-size: 0.8125rem;
color: var(--theme-titulo);
cursor: pointer;
z-index: 60;
transition: all 0.2s, left 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
max-width: 280px;
}
.split-active .split-toggle {
/* Se ajusta automáticamente con margin-left del contenedor */
.entity-btn:hover {
border-color: var(--theme-accent);
}
@media (min-width: 1024px) {
.split-toggle {
display: flex;
}
.entity-btn .entity-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.split-toggle:hover {
background: var(--theme-fill);
.entity-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 300px;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
z-index: 200;
}
.entity-dropdown .dropdown-search {
padding: 0.5rem;
border-bottom: 1px solid var(--theme-borde);
}
.entity-dropdown .dropdown-search input {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 6px;
font-size: 0.8125rem;
color: var(--theme-titulo);
border-color: var(--theme-accent);
outline: none;
}
.split-toggle.active {
background: var(--theme-accent);
color: white;
.entity-dropdown .dropdown-search input:focus {
border-color: var(--theme-accent);
}
.split-toggle-label {
font-weight: 500;
.entity-dropdown .dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: transparent;
border: none;
text-align: left;
font-size: 0.8125rem;
color: var(--theme-titulo);
cursor: pointer;
transition: background 0.15s;
}
/* Info panel */
.info-panel {
background: var(--theme-fill);
border-bottom: 1px solid var(--theme-borde);
padding: 1.5rem 2rem;
.entity-dropdown .dropdown-item:hover,
.entity-dropdown .dropdown-item.active {
background: rgba(201, 167, 81, 0.1);
}
.info-content h4 {
.entity-dropdown .dropdown-item .item-code {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--theme-texto);
margin: 0 0 0.5rem 0;
flex-shrink: 0;
}
.info-content p {
font-size: 0.875rem;
line-height: 1.6;
.entity-dropdown .dropdown-item .item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-dropdown .dropdown-item .item-count {
font-size: 0.6875rem;
color: var(--theme-texto);
margin: 0 0 1.25rem 0;
opacity: 0.6;
}
.variacion {
padding-left: 1rem;
border-left: 2px solid var(--theme-borde);
.entity-dropdown .dropdown-list {
max-height: 240px;
overflow-y: auto;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
/* Tree card */
.tree-card {
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
}
.variacion-rango {
.tree-card-header {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
font-size: 0.625rem;
letter-spacing: 0.12em;
color: var(--theme-texto);
margin-bottom: 1rem;
text-transform: uppercase;
}
.variacion p {
margin-top: 0.25rem;
font-size: 0.813rem;
}
/* Hijos list in info panel */
.hijos-list {
/* Tree hierarchy */
.tree {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1rem;
gap: 0.125rem;
}
.hijo-link {
.tree-node {
display: flex;
align-items: baseline;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 0.5rem;
border-radius: 4px;
padding: 0.5rem 0.75rem;
margin-left: calc(var(--indent, 0) * 1.5rem);
border-left: 2px solid #e8f4fa;
text-decoration: none;
transition: background 0.2s;
border-radius: 0 8px 8px 0;
transition: background 0.2s, border-color 0.2s;
}
.hijo-link:hover {
background: var(--theme-fill);
.tree-node:hover {
background: rgba(214, 234, 245, 0.4);
}
.tree-node-current {
background: #ddf0f8;
border-left-color: #ddf0f8;
border-radius: 8px;
margin-left: calc(var(--indent, 0) * 1.5rem);
}
.tree-node-current:hover {
background: #ddf0f8;
}
.tree-children {
display: flex;
flex-direction: column;
gap: 0.125rem;
margin-left: calc(var(--indent, 0) * 1.5rem);
border-left: 2px solid #f0f7fb;
padding-left: 0;
}
.tree-node-child {
margin-left: 0;
border-left: none;
padding-left: 1rem;
}
.tree-node-child:hover {
background: rgba(214, 234, 245, 0.4);
}
.tree-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ddf0f8;
flex-shrink: 0;
}
.tree-node-current .tree-dot {
background: var(--theme-titulo);
}
.hijo-codigo {
.tree-code {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
font-size: 0.75rem;
color: var(--theme-texto);
min-width: 3rem;
}
.hijo-nombre {
font-size: 0.813rem;
.tree-node-current .tree-code {
color: var(--theme-texto);
}
.hijo-link:hover .hijo-nombre {
.tree-name {
font-size: 0.8125rem;
color: var(--theme-titulo);
flex: 1;
}
/* Main */
main {
padding: 1rem 2rem;
max-width: 900px;
margin: 0 auto;
.tree-node-current .tree-name {
color: var(--theme-titulo);
font-weight: 600;
}
/* Entity selector */
.entity-selector {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
.tree-badge {
font-family: 'DM Mono', monospace;
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-texto);
opacity: 0.5;
padding: 0.125rem 0.375rem;
background: var(--theme-borde);
border-radius: 4px;
}
.interaction-hint {
.tree-node-current .tree-badge {
background: rgba(255, 255, 255, 0.2);
color: white;
opacity: 1;
}
/* Custom Tooltip */
.has-tooltip {
position: relative;
}
.has-tooltip::after {
content: attr(data-tooltip);
position: absolute;
left: 0;
top: 100%;
margin-top: 4px;
padding: 0.5rem 0.75rem;
background: #1a1a1a;
color: #ffffff;
font-size: 0.75rem;
color: var(--theme-texto);
margin-left: auto;
font-weight: 400;
line-height: 1.4;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
white-space: normal;
max-width: 280px;
width: max-content;
z-index: 1000;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
pointer-events: none;
}
.desktop-hint {
display: inline;
.has-tooltip:hover::after {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.mobile-hint {
display: none;
/* Tooltip arrow */
.has-tooltip::before {
content: '';
position: absolute;
left: 12px;
top: 100%;
border: 5px solid transparent;
border-bottom-color: #1a1a1a;
z-index: 1001;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s;
pointer-events: none;
}
@media (max-width: 640px) {
.desktop-hint {
.has-tooltip:hover::before {
opacity: 1;
visibility: visible;
}
/* Hide tooltip on touch devices to avoid sticky tooltips */
@media (hover: none) {
.has-tooltip::after,
.has-tooltip::before {
display: none;
}
.mobile-hint {
display: inline;
}
}
.selector-label {
font-size: 0.813rem;
/* Tree - modo oscuro */
:global(html.dark) .tree-node {
border-left-color: var(--theme-accent);
}
:global(html.dark) .tree-node:hover {
background: rgba(201, 167, 81, 0.1);
}
:global(html.dark) .tree-node-current {
background: var(--theme-accent);
border-left-color: var(--theme-accent);
}
:global(html.dark) .tree-children {
border-left-color: rgba(201, 167, 81, 0.3);
}
:global(html.dark) .tree-node-child:hover {
background: rgba(201, 167, 81, 0.1);
}
:global(html.dark) .tree-dot {
background: var(--theme-accent);
}
/* Vigencia temporal card */
.vigencia-card {
background: var(--theme-surface);
border-radius: 16px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.vigencia-row {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.5rem 0;
}
.vigencia-row:not(:last-child) {
border-bottom: 1px solid var(--theme-borde);
padding-bottom: 0.875rem;
}
.vigencia-icon {
color: var(--theme-texto);
opacity: 0.5;
flex-shrink: 0;
}
.vigencia-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vigencia-status {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-titulo);
}
.vigencia-full {
color: #4A9E6B;
}
.vigencia-partial {
color: #D4893D;
}
.vigencia-changes {
color: #6B9FD4;
}
.vigencia-detail {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.7;
}
.selector-btn {
.vigencia-btn {
font-size: 0.75rem;
font-weight: 500;
color: #6B9FD4;
background: rgba(107, 159, 212, 0.1);
border: none;
padding: 0.375rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.vigencia-btn:hover {
background: rgba(107, 159, 212, 0.2);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
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;
box-shadow: inset 0 0 0 1px var(--theme-borde);
border-radius: 8px;
color: var(--theme-titulo);
font-size: 0.875rem;
padding: 0.25rem;
cursor: pointer;
transition: all 0.2s;
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;
}
.selector-btn:hover {
box-shadow: inset 0 0 0 1px var(--theme-accent);
.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;
}
.selector-btn svg {
.def-text {
font-size: 0.875rem;
color: var(--theme-texto);
line-height: 1.6;
margin: 0;
}
.relative {
position: relative;
/* Modal - modo oscuro */
:global(html.dark) .vigencia-btn {
color: var(--theme-accent);
background: rgba(201, 167, 81, 0.1);
}
.dropdown-panel {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
width: min(500px, 90vw);
max-height: 360px;
overflow: hidden;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
z-index: 50;
:global(html.dark) .vigencia-btn:hover {
background: rgba(201, 167, 81, 0.2);
}
:global(html.dark) .def-current {
border-left-color: var(--theme-accent);
}
:global(html.dark) .def-period {
color: var(--theme-accent);
}
/* Main */
main {
padding: 1rem 2rem;
max-width: 900px;
margin: 0 auto;
}
/* Entity selector */
.entity-selector {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.interaction-hint {
font-size: 0.75rem;
color: var(--theme-texto);
margin-left: auto;
}
.desktop-hint {
display: inline;
}
.mobile-hint {
display: none;
}
@media (max-width: 640px) {
.desktop-hint {
display: none;
}
.mobile-hint {
display: inline;
}
}
/* Old dropdown styles - keeping for comparar view */
.dropdown-search {
padding: 0.5rem;
border-bottom: 1px solid var(--theme-borde);
......@@ -1722,19 +2142,38 @@
}
.chart-protagonista {
margin-bottom: 1.25rem;
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
min-width: 0;
width: 100%;
transition: flex 0.3s ease;
}
.chart-protagonista.expanded {
flex: 1;
}
.chart-protagonista .chart-wrapper {
flex: 1;
display: flex;
align-items: stretch;
min-height: 220px;
transition: min-height 0.3s ease;
}
.chart-protagonista .chart {
height: calc(100vh - 480px);
min-height: 120px;
max-height: 200px;
.chart-protagonista.expanded .chart-wrapper {
min-height: 320px;
}
.chart-protagonista .chart-container {
height: 100%;
}
.chart-protagonista .y-axis {
height: calc(100vh - 480px);
min-height: 120px;
max-height: 200px;
padding-bottom: 1.5rem;
}
.chart-section:has(.chart-comparar) {
......@@ -1747,27 +2186,75 @@
max-height: 280px;
}
.chart-header {
.chart-top-row {
display: flex;
justify-content: space-between;
align-items: baseline;
align-items: center;
margin-bottom: 0.375rem;
}
.main-title-pills {
display: flex;
gap: 0.5rem;
}
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: 6px;
letter-spacing: 0.03em;
}
.pill-nivel {
background: rgba(166, 138, 62, 0.12);
color: #C9A751;
}
.pill-codigo {
background: rgba(107, 159, 212, 0.12);
color: #6B9FD4;
}
.main-title-name {
font-size: 1.375rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.5rem 0;
line-height: 1.2;
}
.chart-header {
margin-bottom: 0.5rem;
}
.chart-title {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.75rem;
font-weight: 500;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.1em;
letter-spacing: 0.08em;
}
.chart-period {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.75rem;
font-weight: 400;
color: var(--theme-texto);
font-variant-numeric: tabular-nums;
min-width: 5rem;
text-align: right;
opacity: 0.7;
text-transform: none;
letter-spacing: 0;
}
.chart-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
min-width: 0;
}
.chart-wrapper {
......@@ -1817,24 +2304,46 @@
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
height: 160px;
padding-bottom: 1.5rem;
padding-right: 0.5rem;
width: 4rem;
flex-shrink: 0;
}
.y-label {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.6875rem;
color: var(--theme-texto);
font-weight: 400;
line-height: 1;
white-space: nowrap;
}
.chart {
.chart-container {
flex: 1;
position: relative;
min-height: 160px;
overflow: hidden;
}
.grid-line {
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed var(--theme-texto);
opacity: 0.3;
pointer-events: none;
}
.chart {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 1.5rem;
display: flex;
align-items: flex-end;
gap: 4px;
height: 160px;
padding: 0 0.5rem;
border-left: 1px solid var(--theme-borde);
}
.bar-container {
......@@ -1849,20 +2358,19 @@
.bar {
width: 100%;
background: #2a9d8f;
background: #555555;
border-radius: 4px 4px 0 0;
transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
min-height: 4px;
min-height: 6px;
}
.bar.hovered {
background: var(--theme-accent);
transform: scaleX(1.1);
}
.bar-label {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.6875rem;
color: var(--theme-texto);
margin-top: 0.5rem;
opacity: 0;
......@@ -1875,133 +2383,128 @@
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
margin-top: 1rem;
}
.kpi {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
padding: 0.75rem 0.75rem;
text-align: center;
min-width: 0;
padding: 0.625rem 0.5rem;
background: var(--theme-body);
border-radius: 8px;
transition: box-shadow 0.3s ease;
border: 1px solid var(--theme-borde);
}
.kpi-value {
display: block;
font-family: 'DM Mono', monospace;
font-size: 1.125rem;
font-weight: 500;
color: var(--theme-titulo);
margin-bottom: 0;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.9375rem;
font-weight: 600;
color: #666666;
margin-bottom: 0.125rem;
transition: opacity 0.2s ease;
font-variant-numeric: tabular-nums;
min-height: 1.5rem;
min-height: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-label {
font-size: 0.5625rem;
color: var(--theme-texto);
font-weight: 600;
color: #666666;
text-transform: uppercase;
letter-spacing: 0.03em;
line-height: 1.3;
transition: opacity 0.2s ease;
min-height: 1.25rem;
min-height: auto;
display: flex;
align-items: center;
justify-content: center;
}
/* Top entidades */
.top-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.625rem;
/* Ranking Simple */
.ranking-section {
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.top-section h3 {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0;
.ranking-title {
font-size: 1.375rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.5rem 0;
line-height: 1.2;
}
.top-context {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
color: var(--theme-texto);
font-variant-numeric: tabular-nums;
.ranking-desc {
font-size: 0.8125rem;
color: #666666;
margin: 0 0 1.25rem 0;
}
.top-list {
.ranking-cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.25rem;
}
.top-item {
.ranking-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
border-radius: 8px;
transition: box-shadow 0.25s ease;
padding: 0.25rem 0;
min-width: 0;
}
.top-item:hover {
box-shadow: inset 0 0 0 1px rgba(201, 167, 81, 0.4);
.ranking-position {
font-family: 'Qanelas', var(--font-sans);
font-size: 1rem;
font-weight: 600;
color: #555555;
flex-shrink: 0;
}
.top-rank {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
color: var(--theme-texto);
width: 24px;
.ranking-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.top-info {
flex: 1;
.ranking-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--theme-borde);
min-width: 0;
}
.top-name {
display: block;
font-size: 0.8125rem;
color: var(--theme-titulo);
margin-bottom: 0.375rem;
.ranking-item:last-child {
border-bottom: none;
}
.top-bar-bg {
height: 4px;
background: var(--theme-surface);
border-radius: 2px;
.ranking-name {
flex: 1;
font-size: 0.8125rem;
font-weight: 400;
color: #666666;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar {
height: 100%;
background: var(--theme-accent);
border-radius: 2px;
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), background 0.25s ease;
}
.top-item:hover .top-bar {
background: var(--theme-accent);
}
.top-pct {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
color: var(--theme-texto);
min-width: 52px;
width: 52px;
.ranking-pct {
font-family: 'Qanelas', var(--font-sans);
font-size: 1rem;
font-weight: 600;
color: #555555;
flex-shrink: 0;
min-width: 3.5rem;
text-align: right;
transition: opacity 0.2s ease;
font-variant-numeric: tabular-nums;
}
.see-all {
......@@ -2021,38 +2524,7 @@
color: var(--theme-accent);
}
/* Vista Toggle */
.vista-toggle {
display: inline-flex;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
border-radius: 10px;
padding: 4px;
margin-bottom: 0.75rem;
}
.toggle-btn {
padding: 0.5rem 1.25rem;
font-size: 0.813rem;
font-weight: 500;
color: var(--theme-texto);
background: transparent;
border: none;
border-radius: 7px;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn:hover {
color: var(--theme-texto);
}
.toggle-btn.active {
background: var(--theme-titulo);
color: var(--theme-body);
}
/* Comparador inline - ajustado para 100vh */
/* Comparador inline */
.comparador-selectors {
display: flex;
align-items: flex-start;
......@@ -2333,22 +2805,120 @@
}
/* Responsive */
@media (max-width: 900px) {
.dashboard-layout {
grid-template-columns: 1fr;
padding: 1rem;
}
.dashboard-context {
height: auto;
}
/* Mobile sidebar toggle */
.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.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 0.5rem;
}
.mobile-sidebar-toggle:hover {
background: var(--theme-surface-hover);
}
.mobile-sidebar-toggle svg {
color: var(--theme-texto);
opacity: 0.7;
}
.toggle-label {
flex: 1;
text-align: left;
}
/* Ocultar contenido del sidebar en mobile por defecto */
.dashboard-context .context-card {
display: none;
height: auto;
}
/* Mostrar cuando está abierto */
.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;
}
.sidebar-card {
flex: 1;
min-width: 200px;
}
.sidebar-separator {
display: none;
}
.sidebar-section {
width: 100%;
}
.sidebar-selectors {
flex-direction: row;
flex-wrap: wrap;
}
}
@media (max-width: 640px) {
header, main {
padding: 1.25rem 1rem;
.page-nav {
padding: 0.75rem 1rem;
}
.title-main {
.toolbar {
padding: 0.75rem 1rem;
flex-direction: column;
gap: 0.25rem;
align-items: stretch;
gap: 0.5rem;
}
.codigo {
font-size: 1.25rem;
.dashboard-layout {
padding: 0.75rem;
}
h1 {
font-size: 1.375rem;
.context-card {
height: auto;
}
.sidebar-selectors {
flex-direction: column;
}
main, .dashboard-main {
padding: 0;
}
.y-axis {
......@@ -2360,21 +2930,21 @@
height: 120px;
}
.chart-protagonista .chart-container {
min-height: 160px;
max-height: 200px;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
.kpi-value {
font-size: 1.125rem;
}
.top-summary {
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
}
.comparador-modal {
padding: 1.5rem;
.kpi-label {
font-size: 0.45rem;
}
.comparador-selectors {
......@@ -2386,21 +2956,27 @@
display: none;
}
.kpi-values {
gap: 1rem;
/* Ranking responsive */
.ranking-title {
font-size: 1.25rem;
}
.ranking-cards {
grid-template-columns: 1fr;
}
.kpi-val {
font-size: 0.75rem;
min-width: 60px;
.ranking-card {
padding: 0.75rem;
}
.comp-bar {
width: 12px;
.ranking-name {
font-size: 0.8125rem;
-webkit-line-clamp: 1;
}
.grouped-chart {
gap: 0.5rem;
.ranking-position,
.ranking-pct {
font-size: 0.875rem;
}
}
......