Rafael Lopez

landing

......@@ -111,35 +111,35 @@
============================================ */
:root {
/* Modo claro (default) - Slate/White */
--theme-body: #fafafa;
--theme-background: #f8fafc;
--theme-titulo: #1a1a1a;
--theme-texto: #64748b;
--theme-fill: #f1f5f9;
--theme-borde: rgba(0, 0, 0, 0.08);
--theme-accent: #c9a751;
--theme-accent-hover: #b8963f;
/* Modo claro (default) - Estilo Apple, limpio y con contraste */
--theme-body: #ffffff;
--theme-background: #ffffff;
--theme-titulo: #1d1d1f;
--theme-texto: #424245;
--theme-fill: #ffffff;
--theme-borde: rgba(0, 0, 0, 0.04);
--theme-accent: #9A8550;
--theme-accent-hover: #857040;
/* Superficies alternativas */
--theme-surface: #ffffff;
--theme-surface-hover: #f8fafc;
--theme-surface-hover: #f5f5f7;
}
:root.dark {
/* Modo oscuro */
--theme-body: #0d0d0d;
--theme-background: #1a1a1a;
--theme-titulo: #e8e4d8;
--theme-texto: #b8b3a3;
--theme-fill: #2d2d2d;
--theme-borde: rgba(255, 255, 255, 0.1);
--theme-accent: #c9a751;
--theme-accent-hover: #d4b76a;
/* Modo oscuro - Cálido como el landing (#1C1C1A base) */
--theme-body: #1C1C1A;
--theme-background: #242422;
--theme-titulo: #F5F0E8;
--theme-texto: #B8B5AD;
--theme-fill: #2A2A27;
--theme-borde: rgba(255, 255, 255, 0.08);
--theme-accent: #C9A751;
--theme-accent-hover: #D4B76A;
/* Superficies alternativas */
--theme-surface: #1a1a1a;
--theme-surface-hover: #2d2d2d;
--theme-surface: #232321;
--theme-surface-hover: #2A2A27;
}
/* ============================================
......
......@@ -49,6 +49,7 @@
<div class="relative group/home">
<a
href="/"
data-sveltekit-preload-data="off"
class="nav-icon p-2 rounded-full transition-all block"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
......
......@@ -35,8 +35,8 @@ const createClasificadorCache = () => {
const cache = currentCache.objetoGasto;
if (!cache.lastFetched) return null;
// Cache valid for 5 minutes
const isValid = Date.now() - cache.lastFetched < 5 * 60 * 1000;
// Cache valid for 30 minutes
const isValid = Date.now() - cache.lastFetched < 30 * 60 * 1000;
if (!isValid) return null;
return cache;
......
import { writable } from 'svelte/store';
export const isNavigating = writable(false);
export const navigationTarget = writable(null);
<script>
import '../app.css';
import { page } from '$app/stores';
import { page, navigating } from '$app/stores';
import favicon from '$lib/assets/favicon.svg';
import Navbar from '$lib/components/ui/Navbar.svelte';
import NavigationDrawer from '$lib/components/layout/NavigationDrawer.svelte';
import { openDrawer } from '$lib/stores/drawer.js';
import Spinner from '$lib/components/ui/Spinner.svelte';
let { children } = $props();
// Don't show navbar on landing page
let isLanding = $derived($page.url.pathname === '/');
// Navigation loading state
let showLoader = $state(false);
let loaderTimeout = null;
let navStartTime = null;
// Show loader after 150ms delay (avoid flash for fast navigations)
$effect(() => {
if ($navigating) {
navStartTime = performance.now();
console.log(`[NAV] Navigation started → ${$navigating.to?.url?.pathname}`);
loaderTimeout = setTimeout(() => {
showLoader = true;
}, 150);
} else {
if (loaderTimeout) clearTimeout(loaderTimeout);
if (navStartTime) {
console.log(`[NAV] Navigation complete: ${(performance.now() - navStartTime).toFixed(0)}ms`);
navStartTime = null;
}
showLoader = false;
}
});
</script>
<svelte:head>
......@@ -26,8 +50,55 @@
<Navbar onOpenDrawer={openDrawer} />
{/if}
<!-- Navigation loading overlay -->
{#if showLoader}
<div class="nav-loader">
<div class="nav-loader-content">
<Spinner size={32} color="#C9A751" />
<span class="nav-loader-text">Cargando...</span>
</div>
</div>
{/if}
<div class="min-h-screen" style="background-color: var(--theme-body);">
<main>
{@render children()}
</main>
</div>
<style>
.nav-loader {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(28, 28, 26, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.15s ease-out;
}
.nav-loader-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.nav-loader-text {
font-family: 'DM Mono', monospace;
font-size: 13px;
color: #B8B5AD;
letter-spacing: 0.05em;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
......
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { supabase } from '$lib/supabase';
import {
query,
results,
......@@ -15,6 +16,13 @@
navigateResults
} from '$lib/stores/searchStore';
// Pre-warm Supabase connection (evita cold start al navegar)
async function warmupSupabase() {
console.time('[WARMUP] Supabase');
await supabase.schema('ppto').from('vista_objeto_estado').select('objeto').limit(1);
console.timeEnd('[WARMUP] Supabase');
}
let mounted = false;
let mode = 'archivo';
let searchFocused = false;
......@@ -56,6 +64,8 @@
onMount(() => {
setTimeout(() => { mounted = true; }, 80);
initIndex();
// Pre-calentar Supabase en background para evitar cold start
warmupSupabase();
});
function toggleMobileSettings() {
......@@ -791,7 +801,7 @@
<div class="descargas-formats">
<span class="format-pill">.csv</span>
<span class="format-pill">.parquet</span>
<span class="format-pill">.json</span>
<span class="format-pill">.gpkg</span>
</div>
<a href="/descargas" class="descargas-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v12"/><polyline points="8 12 12 16 16 12"/><path d="M4 18h16"/></svg>
......@@ -872,7 +882,7 @@
.hero-search{display:flex;justify-content:center;position:relative;z-index:50}
.hero-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.25em;color:#B8B5AD}
.hero-title{font-size:clamp(44px,5.5vw,72px);font-weight:900;line-height:1.02;letter-spacing:-0.035em;max-width:720px;margin:14px auto 0}
.hero-sub{font-family:var(--sans);font-size:clamp(16px,1.6vw,18px);color:#9B9890;max-width:100%;margin-top:14px;line-height:1.6}
.hero-sub{font-family:var(--sans);font-size:clamp(16px,1.6vw,18px);color:#A5A29A;max-width:100%;margin-top:14px;line-height:1.6}
.hero-stats{font-family:var(--mono);font-size:13px;letter-spacing:0.04em;color:#B8B5AD;margin-top:16px;line-height:1}
.stat-num{color:#F5F0E8}
.stat-dot{margin:0 10px;opacity:0.3}
......
......@@ -16,6 +16,19 @@
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');
......@@ -300,6 +313,11 @@
return;
}
// No calcular si las dimensiones no están listas
if (treemapWidth <= 0 || treemapHeight <= 0) {
return;
}
const treemap = d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(4)
......@@ -467,55 +485,59 @@
}
}
// Manejar resize del contenedor (solo una vez al montar)
// Manejar resize del contenedor - usando onMount para evitar conflictos de efectos
let resizeObserver = null;
let scrollHandler = null;
$effect(() => {
if (treemapContainer && viewMode === 'mapa') {
// Función para actualizar dimensiones y recalcular layout
const updateDimensions = () => {
const rect = treemapContainer.getBoundingClientRect();
if (rect.width > 0) {
const newWidth = Math.floor(rect.width);
// Calcular altura disponible para que quepa en viewport
const viewportHeight = window.innerHeight;
const paddingBottom = 24;
const effectiveTop = Math.max(rect.top, 0);
const availableHeight = viewportHeight - effectiveTop - paddingBottom;
const newHeight = Math.max(250, Math.floor(availableHeight));
if (newWidth !== treemapWidth || newHeight !== treemapHeight) {
treemapWidth = newWidth;
treemapHeight = newHeight;
recalculateCurrentLayout();
}
// Función para actualizar dimensiones del treemap
function updateTreemapDimensions() {
if (!treemapContainer || viewMode !== 'mapa') return;
const rect = treemapContainer.getBoundingClientRect();
if (rect.width > 0) {
const newWidth = Math.floor(rect.width);
const viewportHeight = window.innerHeight;
const paddingBottom = 24;
const effectiveTop = Math.max(rect.top, 0);
const availableHeight = viewportHeight - effectiveTop - paddingBottom;
const newHeight = Math.max(250, Math.floor(availableHeight));
const dimensionsChanged = newWidth !== treemapWidth || newHeight !== treemapHeight;
if (dimensionsChanged) {
treemapWidth = newWidth;
treemapHeight = newHeight;
}
// Recalcular si hay datos y dimensiones válidas
if (currentTreemapNode && currentTreemapNode.children && treemapWidth > 0 && treemapHeight > 0) {
if (dimensionsChanged || treemapNodes.length === 0) {
recalculateCurrentLayout();
}
};
}
}
}
// Calcular dimensiones iniciales con pequeño delay para asegurar render
setTimeout(updateDimensions, 50);
// Efecto simple para setup/cleanup de ResizeObserver
$effect(() => {
const container = treemapContainer;
const mode = viewMode;
// Usar ResizeObserver para cambios de tamaño
resizeObserver = new ResizeObserver(() => {
updateDimensions();
if (container && mode === 'mapa') {
// Calcular dimensiones iniciales
requestAnimationFrame(() => {
updateTreemapDimensions();
});
resizeObserver.observe(treemapContainer);
// También escuchar scroll para recalcular cuando el usuario hace scroll
scrollHandler = () => {
updateDimensions();
};
window.addEventListener('scroll', scrollHandler, { passive: true });
// Setup ResizeObserver
const observer = new ResizeObserver(() => {
requestAnimationFrame(updateTreemapDimensions);
});
observer.observe(container);
resizeObserver = observer;
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
if (scrollHandler) {
window.removeEventListener('scroll', scrollHandler);
}
observer.disconnect();
resizeObserver = null;
};
}
});
......@@ -876,6 +898,45 @@
return Math.max(...years.map(y => parseInt(y)));
}
/**
* Converts a string with years into compact ranges
* Example: "2006, 2007, 2008, 2009, 2015, 2016" → "2006-2009, 2015-2016"
* Single years: "2006, 2008, 2010" → "2006, 2008, 2010"
*/
function formatYearsAsRanges(rangosStr) {
if (!rangosStr) return '';
// Extract all 4-digit years
const yearsMatch = rangosStr.match(/\d{4}/g);
if (!yearsMatch || yearsMatch.length === 0) return rangosStr;
// Convert to numbers and sort
const years = [...new Set(yearsMatch.map(y => parseInt(y)))].sort((a, b) => a - b);
if (years.length === 1) return String(years[0]);
// Group consecutive years into ranges
const ranges = [];
let rangeStart = years[0];
let rangeEnd = years[0];
for (let i = 1; i < years.length; i++) {
if (years[i] === rangeEnd + 1) {
// Consecutive year, extend range
rangeEnd = years[i];
} else {
// Gap found, save current range and start new one
ranges.push(rangeStart === rangeEnd ? String(rangeStart) : `${rangeStart}-${rangeEnd}`);
rangeStart = years[i];
rangeEnd = years[i];
}
}
// Add the last range
ranges.push(rangeStart === rangeEnd ? String(rangeStart) : `${rangeStart}-${rangeEnd}`);
return ranges.join(', ');
}
function selectGrupo(grupo) {
selectedGrupo = grupo;
selectedItem = null;
......@@ -953,48 +1014,48 @@
<svelte:window onclick={handleClickOutsideEntity} />
<div class="min-h-screen" style="font-family: 'Instrument Sans', sans-serif; background-color: var(--theme-body); color: var(--theme-titulo); transition: background-color 0.2s, color 0.2s;">
<div class="min-h-screen" style="font-family: var(--font-sans); background-color: var(--theme-body); color: var(--theme-titulo);">
<!-- Header pedagógico -->
<header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);">
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 {viewMode === 'mapa' ? 'py-3' : 'py-6'}">
<!-- Breadcrumb: responsive -->
<nav class="mb-4" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
<nav class="mb-4" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: 0.75rem;">
<!-- Móvil: solo padre -->
<a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
← Clasificadores
</a>
<!-- Desktop: ruta completa -->
<div class="hidden sm:flex items-center gap-2" style="color: var(--theme-texto);">
<a href="/" class="transition-colors hover:opacity-80">Inicio</a>
<a href="/" data-sveltekit-preload-data="off" class="hover:opacity-100" style="opacity: 0.7;">Inicio</a>
<span style="opacity: 0.5;">/</span>
<a href="/clasificadores" class="transition-colors hover:opacity-80">Clasificadores</a>
<a href="/clasificadores" data-sveltekit-preload-data="off" class="hover:opacity-100" style="opacity: 0.7;">Clasificadores</a>
</div>
</nav>
<div class="max-w-3xl">
<p class="text-xs uppercase tracking-widest mb-2" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
Clasificador 03
<p class="text-sm uppercase tracking-widest mb-2 font-semibold" style="font-family: var(--font-sans); color: var(--theme-accent);">
Clasificador de Objeto del Gasto
</p>
<h1 class="{viewMode === 'mapa' ? 'text-2xl mb-2' : 'text-3xl mb-3'}" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
<h1 class="{viewMode === 'mapa' ? 'text-2xl mb-2' : 'text-3xl mb-3'}" style="font-family: var(--font-display); color: var(--theme-titulo);">
¿En qué se gasta?
</h1>
{#if viewMode !== 'mapa'}
<p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
<p class="text-lg leading-relaxed mb-4" style="color: var(--theme-texto);">
Ordena el gasto según <strong style="color: var(--theme-titulo);">qué se compra o paga</strong>:
sueldos, alquileres, combustible, medicamentos, construcciones, deudas, transferencias.
Es la forma más directa de entender en qué se usa el dinero público.
</p>
{#if totalItems > 0}
<p class="text-sm mb-5" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
<span style="color: var(--theme-titulo); font-weight: 500;">{totalItems}</span> categorías de gasto:
<p class="text-base mb-5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; color: var(--theme-texto);">
<span style="color: var(--theme-accent); font-weight: 600;">{totalItems}</span> categorías de gasto:
{countByNivel.grupos} grupos, {countByNivel.subgrupos} subgrupos, {countByNivel.partidas} partidas, {countByNivel.subpartidas} subpartidas
</p>
{/if}
<!-- Jerarquía -->
<div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-xs mb-6" style="font-family: 'DM Mono', monospace;">
<div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-sm mb-6" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
<span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body); opacity: 0.9;">
Grupo
</span>
......@@ -1012,7 +1073,7 @@
</span>
</div>
<!-- Versión móvil simplificada -->
<p class="sm:hidden text-sm mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
<p class="sm:hidden text-sm mb-4" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; color: var(--theme-texto);">
Jerarquía: Grupo → Subgrupo → Partida → Subpartida
</p>
{/if}
......@@ -1085,12 +1146,40 @@
</div>
</div>
<!-- Filtros (solo en modo mapa) -->
{#if viewMode === 'mapa'}
<div class="flex flex-wrap items-center gap-3 flex-shrink-0">
<!-- Buscador + Filtros -->
<div class="flex flex-wrap items-center gap-3 flex-shrink-0">
<!-- Buscador inline (estilo similar al toggle) -->
<div class="inline-flex items-center rounded-xl border-2 p-1 shadow-sm" style="border-color: var(--theme-titulo); background-color: var(--theme-surface);">
<div class="flex items-center px-3" style="color: var(--theme-texto);">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar código o nombre..."
class="px-2 py-1.5 text-sm font-medium bg-transparent focus:outline-none w-48 sm:w-56"
style="color: var(--theme-titulo);"
/>
{#if searchQuery}
<button
class="px-2 py-1.5 rounded-lg transition-all hover:bg-black/5"
onclick={() => searchQuery = ''}
style="color: var(--theme-texto);"
title="Limpiar búsqueda"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{#if viewMode === 'mapa'}
<!-- Selector de nivel -->
<div class="flex items-center gap-2 level-dropdown-container">
<label class="text-xs uppercase tracking-wide" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">Vista</label>
<label class="text-xs uppercase tracking-wide" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; color: var(--theme-texto);">Vista</label>
<div class="relative">
<button
class="dropdown-control px-3 py-1.5 text-sm rounded-lg focus:outline-none min-w-[140px] text-left flex items-center justify-between gap-2"
......@@ -1119,7 +1208,7 @@
</div>
<div class="flex items-center gap-2 year-dropdown-container">
<label class="text-xs uppercase tracking-wide" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">Año</label>
<label class="text-xs uppercase tracking-wide" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; color: var(--theme-texto);">Año</label>
<div class="relative">
<button
class="dropdown-control px-3 py-1.5 text-sm rounded-lg focus:outline-none min-w-[80px] text-left flex items-center justify-between gap-2"
......@@ -1147,7 +1236,7 @@
</div>
<div class="flex items-center gap-2 entity-dropdown-container">
<label class="text-xs uppercase tracking-wide" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">Entidad</label>
<label class="text-xs uppercase tracking-wide" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; color: var(--theme-texto);">Entidad</label>
<div class="relative">
<button
class="dropdown-control px-3 py-1.5 text-sm rounded-lg focus:outline-none min-w-[180px] text-left flex items-center justify-between gap-2"
......@@ -1210,9 +1299,9 @@
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- Guía móvil (solo visible en móvil, no en comparar) -->
{#if viewMode !== 'comparar'}
......@@ -1275,7 +1364,7 @@
</button>
</div>
<div class="max-w-screen-xl mx-auto flex px-4">
<div class="max-w-screen-xl mx-auto flex px-4 lg:px-6">
<!-- Sidebar izquierda: Grupos -->
<!-- En móvil: overlay, en desktop: sidebar fijo -->
{#if sidebarOpen}
......@@ -1286,7 +1375,7 @@
lg:translate-x-0
fixed lg:relative
inset-y-0 left-0
w-72 lg:w-64
w-80 lg:w-72 xl:w-80
z-50 lg:z-auto
transition-transform duration-200 ease-in-out
lg:flex-shrink-0
......@@ -1304,32 +1393,21 @@
</button>
</div>
<!-- Buscador -->
<div class="mb-6">
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar..."
class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
/>
</div>
<!-- Resultados de búsqueda -->
<!-- Resultados de búsqueda (el buscador está en el header) -->
{#if isSearching}
<div class="mb-4">
<p class="text-xs uppercase tracking-wide mb-2" style="color: var(--theme-texto);">
<p class="text-sm uppercase tracking-wide mb-2" style="color: var(--theme-texto);">
{searchResults.length} resultados
</p>
<div class="space-y-1">
<div class="space-y-1.5">
{#each searchResults as result}
<button
class="search-result-item w-full text-left px-3 py-3 text-sm rounded-lg transition-all cursor-pointer"
class="search-result-item w-full text-left px-3 py-3 text-base rounded-lg transition-all cursor-pointer"
onclick={() => goToSearchResult(result)}
>
<span class="text-xs block mb-0.5" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span>
<span class="text-sm block mb-0.5" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span>
<div class="flex items-baseline gap-2">
<span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.objeto}</span>
<span class="font-mono text-sm font-medium" style="color: var(--theme-accent);">{result.objeto}</span>
<span class="flex-1" style="color: var(--theme-titulo);">{result.desc_objeto}</span>
<svg class="w-4 h-4 flex-shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
......@@ -1345,19 +1423,19 @@
{:else}
<!-- Lista de grupos -->
<nav>
<p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Grupos</p>
<p class="text-base uppercase tracking-wide mb-4 px-2 hidden lg:block font-medium" style="color: var(--theme-texto);">Índice</p>
<ul class="space-y-1">
{#each grupos as grupo}
<li>
<button
class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
class="w-full text-left px-3 py-3 rounded-md text-base transition-all"
style="{selectedGrupo?.objeto === grupo.objeto
? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent); color: var(--theme-titulo); font-weight: 500; border-left: 2px solid var(--theme-accent);`
? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent); color: var(--theme-titulo); font-weight: 500; border-left: 3px solid var(--theme-accent);`
: `color: var(--theme-texto);`}"
onclick={() => selectGrupo(grupo)}
>
<span class="font-mono text-xs block" style="color: var(--theme-texto);">{grupo.objeto}</span>
{grupo.desc_objeto}
<span class="font-mono text-base block mb-0.5" style="color: var(--theme-accent);">{grupo.objeto}</span>
<span class="leading-snug">{grupo.desc_objeto}</span>
</button>
</li>
{/each}
......@@ -1373,14 +1451,15 @@
{#if selectedGrupo}
<!-- Título del grupo -->
<div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
<p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">{selectedGrupo.objeto}</p>
<h2 class="text-lg sm:text-xl font-medium mb-2 flex items-center gap-2 sm:gap-3 flex-wrap" style="color: var(--theme-titulo);">
<p class="text-base font-mono mb-1" style="color: var(--theme-texto);">{selectedGrupo.objeto}</p>
<h2 class="text-xl sm:text-2xl font-semibold mb-2 flex items-center gap-2 sm:gap-3 flex-wrap" style="color: var(--theme-titulo);">
{selectedGrupo.desc_objeto}
<a
href="/objeto/{selectedGrupo.objeto}"
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" />
......@@ -1412,14 +1491,14 @@
class="scroll-mt-4"
style="{highlightedItem === subgrupo.objeto ? `box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent); border-radius: 0.5rem;` : ''}"
>
<div class="flex items-start gap-2 sm:gap-4 mb-3">
<span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{subgrupo.objeto}</span>
<div class="flex items-start gap-3 sm:gap-4 mb-3">
<span class="font-mono text-base pt-0.5" style="color: var(--theme-accent); font-weight: 500;">{subgrupo.objeto}</span>
<div class="flex-1">
<h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<h3 class="text-lg font-semibold flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<span class="text-left">{subgrupo.desc_objeto}</span>
{#if subgrupo.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
class="text-sm font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(subgrupo)}
title="Ver variaciones de descripción"
>
......@@ -1431,6 +1510,7 @@
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" />
......@@ -1438,7 +1518,7 @@
</a>
</h3>
{#if subgrupoDescs.length > 0}
<p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{subgrupoDescs[0].descripcion}</p>
<p class="text-base mt-1 leading-relaxed" style="color: var(--theme-texto);">{subgrupoDescs[0].descripcion}</p>
{/if}
</div>
</div>
......@@ -1453,10 +1533,10 @@
class="scroll-mt-4"
style="{highlightedItem === partida.objeto ? `box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent); border-radius: 0.5rem; padding: 0.5rem; margin-left: -0.5rem;` : ''}"
>
<div class="flex items-start gap-2 sm:gap-3">
<span class="font-mono text-xs pt-0.5 shrink-0" style="color: var(--theme-texto); {partida._sintetica ? 'opacity: 0.5;' : ''}">{partida.objeto}</span>
<div class="flex items-start gap-3 sm:gap-4">
<span class="font-mono text-base pt-0.5 shrink-0" style="color: var(--theme-texto); {partida._sintetica ? 'opacity: 0.5;' : ''}">{partida.objeto}</span>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<h4 class="text-base font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
{#if partida._sintetica}
<span class="text-left opacity-60 italic">(Partida inferida)</span>
{:else}
......@@ -1464,7 +1544,7 @@
{/if}
{#if partida.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
class="text-sm font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(partida)}
title="Ver variaciones de descripción"
>
......@@ -1477,15 +1557,16 @@
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-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
</a>
{/if}
</h4>
{#if partidaDescs.length > 0 && !partida._sintetica}
<p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{partidaDescs[0].descripcion}</p>
<p class="text-base mt-1 leading-relaxed" style="color: var(--theme-texto);">{partidaDescs[0].descripcion}</p>
{/if}
</div>
</div>
......@@ -1500,14 +1581,14 @@
class="scroll-mt-4"
style="{highlightedItem === subpartida.objeto ? `box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent); border-radius: 0.25rem; padding: 0.25rem; margin-left: -0.25rem;` : ''}"
>
<div class="flex items-start gap-2">
<span class="font-mono text-xs" style="color: var(--theme-texto);">{subpartida.objeto}</span>
<div class="flex items-start gap-3">
<span class="font-mono text-base" style="color: var(--theme-texto);">{subpartida.objeto}</span>
<div class="flex-1">
<span class="flex items-center gap-2">
<span class="text-sm" style="color: var(--theme-titulo);">{subpartida.desc_objeto}</span>
<span class="text-base" style="color: var(--theme-titulo);">{subpartida.desc_objeto}</span>
{#if subpartida.n_variaciones > 1}
<button
class="text-xs text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
class="text-sm text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(subpartida)}
title="Ver variaciones de descripción"
>
......@@ -1519,14 +1600,15 @@
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-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
</a>
</span>
{#if subpartidaDescs.length > 0}
<p class="text-sm mt-1" style="color: var(--theme-texto);">{subpartidaDescs[0].descripcion}</p>
<p class="text-base mt-1" style="color: var(--theme-texto);">{subpartidaDescs[0].descripcion}</p>
{/if}
</div>
</div>
......@@ -1545,36 +1627,36 @@
</div>
</main>
<!-- Sidebar derecha: En esta página -->
<aside class="w-48 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto py-8 pl-8 border-l" style="border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-3" style="color: var(--theme-texto);">En esta página</p>
<!-- Sidebar derecha: Navegación rápida -->
<aside class="w-56 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto py-8 pl-6 border-l" style="border-color: var(--theme-borde);">
<p class="text-sm uppercase tracking-wide mb-4 font-medium" style="color: var(--theme-texto);">Navegación</p>
{#if selectedGrupo && !isSearching}
<nav class="space-y-2">
<nav class="space-y-3">
{#each grupoContent.subgrupos as subgrupo}
<div>
<a
href="#sg-{subgrupo.objeto}"
class="block text-sm truncate transition-colors hover:opacity-80"
class="block text-base truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-titulo);"
title="{subgrupo.desc_objeto}"
>
{subgrupo.desc_objeto}
</a>
{#if subgrupo.partidas?.length > 0}
<div class="ml-3 mt-1 space-y-1 border-l pl-2" style="border-color: color-mix(in srgb, var(--theme-borde) 50%, transparent);">
{#each subgrupo.partidas.slice(0, 5) as partida}
<div class="ml-3 mt-2 space-y-1.5 border-l pl-3" style="border-color: color-mix(in srgb, var(--theme-borde) 50%, transparent);">
{#each subgrupo.partidas.slice(0, 4) as partida}
<a
href="#p-{partida.objeto}"
class="block text-xs truncate transition-colors hover:opacity-80"
class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="{partida.desc_objeto}"
>
{partida.desc_objeto}
</a>
{/each}
{#if subgrupo.partidas.length > 5}
<span class="text-xs" style="color: var(--theme-texto);">+{subgrupo.partidas.length - 5} más</span>
{#if subgrupo.partidas.length > 4}
<span class="text-sm" style="color: var(--theme-texto); opacity: 0.7;">+{subgrupo.partidas.length - 4} más</span>
{/if}
</div>
{/if}
......@@ -1597,7 +1679,7 @@
<div class="flex items-center justify-between mb-2">
<!-- Breadcrumb de navegación (solo en modo jerárquico) -->
{#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 0}
<nav class="flex items-center gap-2 flex-wrap" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
<nav class="flex items-center gap-2 flex-wrap" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: 0.75rem;">
{#each treemapBreadcrumb as crumb, i}
{#if i > 0}
<span style="color: var(--theme-texto); opacity: 0.5;">›</span>
......@@ -1615,7 +1697,7 @@
</nav>
{:else if treemapViewLevel !== 'jerarquico'}
<!-- Info de nivel aplanado -->
<div class="flex items-center gap-3" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
<div class="flex items-center gap-3" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: 0.75rem;">
<span class="px-2 py-1 rounded" style="background-color: var(--theme-fill); color: var(--theme-titulo);">
{NIVEL_OPTIONS.find(o => o.value === treemapViewLevel)?.label || treemapViewLevel}
</span>
......@@ -1683,7 +1765,7 @@
{#if width > 60 && height > 40}
<foreignObject x="6" y="6" width={width - 12} height={height - 12}>
<div class="h-full flex flex-col justify-between overflow-hidden" style="font-family: 'Instrument Sans', sans-serif;">
<div class="h-full flex flex-col justify-between overflow-hidden" style="font-family: var(--font-sans);">
<div class="font-medium leading-tight" style="font-size: {Math.min(14, Math.max(10, width / 12))}px; color: {textColor};">
{#if width > 100}
{label}
......@@ -1692,7 +1774,7 @@
{/if}
</div>
{#if height > 55}
<div style="font-family: 'DM Mono', monospace; font-size: {Math.min(12, Math.max(9, width / 14))}px; color: {textColorSecondary};">
<div style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: {Math.min(12, Math.max(9, width / 14))}px; color: {textColorSecondary};">
{formatMoney(node.value)}
{#if width > 90 && height > 70}
<span style="opacity: 0.7;"> · {formatPerCapita(node.value)}</span>
......@@ -1753,7 +1835,7 @@
fill={getNodeColor(grupoId, 1)}
font-size="10"
font-weight="600"
style="font-family: 'DM Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px;"
style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; text-transform: uppercase; letter-spacing: 0.5px;"
>
{#if width > 180}
{node.name}
......@@ -1787,7 +1869,7 @@
{#if width > 40 && height > 25}
<foreignObject x="4" y="4" width={width - 8} height={height - 8}>
<div class="h-full flex flex-col justify-between overflow-hidden" style="font-family: 'Instrument Sans', sans-serif;">
<div class="h-full flex flex-col justify-between overflow-hidden" style="font-family: var(--font-sans);">
<div class="font-medium leading-tight" style="font-size: {Math.min(12, Math.max(9, width / 14))}px; color: {textColor};">
{#if width > 80}
{(node.name || node.id).slice(0, Math.floor(width / 6))}{(node.name || node.id).length > Math.floor(width / 6) ? '…' : ''}
......@@ -1796,7 +1878,7 @@
{/if}
</div>
{#if height > 40 && width > 50}
<div style="font-family: 'DM Mono', monospace; font-size: {Math.min(10, Math.max(8, width / 16))}px; color: {textColorSecondary};">
<div style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: {Math.min(10, Math.max(8, width / 16))}px; color: {textColorSecondary};">
{formatMoney(node.value)}
{#if width > 80 && height > 55}
<span style="opacity: 0.7;"> · {formatPerCapita(node.value)}</span>
......@@ -1822,31 +1904,31 @@
top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
background-color: var(--theme-titulo);
color: var(--theme-body);
font-family: 'Instrument Sans', sans-serif;
font-family: var(--font-sans);
"
>
{#if treemapViewLevel !== 'jerarquico'}
<!-- Vista aplanada: mostrar contexto del grupo -->
<div class="text-xs opacity-60 mb-1" style="font-family: 'DM Mono', monospace;">
<div class="text-xs opacity-60 mb-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{hoveredNode.id}
</div>
<div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div>
<div class="text-xs opacity-80 mt-1" style="font-family: 'DM Mono', monospace;">
<div class="text-xs opacity-80 mt-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatMoney(hoveredNode.value)} · {((hoveredNode.value / totalValue) * 100).toFixed(1)}%
</div>
<div class="text-xs opacity-60 mt-0.5" style="font-family: 'DM Mono', monospace;">
<div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatPerCapita(hoveredNode.value)}
</div>
{:else}
<!-- Vista jerárquica -->
<div class="font-medium text-sm">{hoveredNode.data?.desc_objeto || hoveredNode.name || hoveredNode.id}</div>
<div class="text-xs opacity-80 mt-1" style="font-family: 'DM Mono', monospace;">
<div class="text-xs opacity-80 mt-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatMoney(hoveredNode.value)}
{#if currentTreemapNode && currentTreemapNode.value}
· {((hoveredNode.value / currentTreemapNode.value) * 100).toFixed(1)}%
{/if}
</div>
<div class="text-xs opacity-60 mt-0.5" style="font-family: 'DM Mono', monospace;">
<div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatPerCapita(hoveredNode.value)}
</div>
{#if hoveredNode._originalNode?.children && hoveredNode._originalNode.children.length > 0}
......@@ -2070,7 +2152,7 @@
<!-- Resumen de diferencias (placeholder) -->
<div class="border rounded-xl p-6" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
<h3 class="text-sm font-medium uppercase tracking-wide mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-titulo);">
<h3 class="text-sm font-medium uppercase tracking-wide mb-4" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; color: var(--theme-titulo);">
Diferencias principales
</h3>
<div class="grid sm:grid-cols-3 gap-4 text-center">
......@@ -2136,7 +2218,7 @@
<span class="font-medium" style="color: var(--theme-accent);">Vigente</span>
<span class="mx-1" style="opacity: 0.5;">·</span>
{/if}
<span class="font-mono">{desc.rangos}</span>
<span class="font-mono">{formatYearsAsRanges(desc.rangos)}</span>
</p>
<p class="text-base leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion}</p>
</div>
......@@ -2262,28 +2344,28 @@
box-shadow: 0 0 0 2px var(--theme-accent);
}
/* Tema claro: transparente + ring sutil */
/* Tema claro: fondo blanco con sombra sutil */
:global(.light) .dropdown-control,
:global(html:not(.dark)) .dropdown-control {
background: transparent;
background: #ffffff;
border: none;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
box-shadow: 0 1px 3px rgba(28,28,26,0.06), inset 0 0 0 1px rgba(28,28,26,0.06);
}
:global(.light) .dropdown-control:focus,
:global(html:not(.dark)) .dropdown-control:focus {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.15);
box-shadow: 0 1px 3px rgba(28,28,26,0.08), inset 0 0 0 1px rgba(28,28,26,0.12);
}
:global(.light) .dropdown-control:hover,
:global(html:not(.dark)) .dropdown-control:hover {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.12);
box-shadow: 0 2px 6px rgba(28,28,26,0.08), inset 0 0 0 1px rgba(28,28,26,0.08);
}
/* Dropdown panels en tema claro */
:global(.light) .dropdown-panel,
:global(html:not(.dark)) .dropdown-panel {
background: #FAFAF8 !important;
background: #ffffff !important;
border: none !important;
box-shadow: 0 4px 20px rgba(0,0,0,0.08), inset 0 0 0 1px rgba(0,0,0,0.08) !important;
box-shadow: 0 4px 24px rgba(28,28,26,0.12), inset 0 0 0 1px rgba(28,28,26,0.06) !important;
}
/* Sidebar izquierdo: con fondo en móvil, transparente en desktop */
......@@ -2329,13 +2411,13 @@
/* Tema claro */
:global(.light) .search-result-item,
:global(html:not(.dark)) .search-result-item {
background: transparent;
background: #ffffff;
border: none;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06);
box-shadow: 0 1px 3px rgba(28,28,26,0.04), inset 0 0 0 1px rgba(28,28,26,0.05);
}
:global(.light) .search-result-item:hover,
:global(html:not(.dark)) .search-result-item:hover {
background: rgba(0,0,0,0.03);
box-shadow: inset 0 0 0 1px var(--theme-accent);
background: #ffffff;
box-shadow: 0 2px 8px rgba(28,28,26,0.08), inset 0 0 0 1px var(--theme-accent);
}
</style>
......
......@@ -2,13 +2,16 @@ import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
console.time('[SERVER] Total load objeto');
const { codigo } = params;
console.time('[SERVER] Query clas_objetos');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', codigo);
console.timeEnd('[SERVER] Query clas_objetos');
if (dbError || !data || data.length === 0) {
throw error(404, 'Objeto no encontrado');
......@@ -21,6 +24,7 @@ export async function load({ params }) {
let padres = [];
let hijos = [];
console.time('[SERVER] Query padres');
// Buscar padre según nivel
if (objeto.nivel === 'subpartida') {
// Padre es partida
......@@ -58,7 +62,9 @@ export async function load({ params }) {
.eq('objeto', grupoCodigo);
if (padreData?.length) padres.unshift(padreData[0]);
}
console.timeEnd('[SERVER] Query padres');
console.time('[SERVER] Query hijos');
// Buscar hijos según nivel
if (objeto.nivel === 'grupo') {
const { data: hijosData } = await supabase
......@@ -100,7 +106,9 @@ export async function load({ params }) {
return acc;
}, []) || [];
}
console.timeEnd('[SERVER] Query hijos');
console.timeEnd('[SERVER] Total load objeto');
return {
objeto,
padres,
......
......@@ -3,6 +3,7 @@
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { supabase } from '$lib/supabase';
import { goto } from '$app/navigation';
import Spinner from '$lib/components/ui/Spinner.svelte';
// Datos del servidor (reactivos)
......@@ -47,6 +48,35 @@
return Math.max(...years.map(y => parseInt(y)));
}
/**
* Converts a string with years into compact ranges
* Example: "2006, 2007, 2008, 2009, 2015, 2016" → "2006-2009, 2015-2016"
*/
function formatYearsAsRanges(rangosStr) {
if (!rangosStr) return '';
const yearsMatch = rangosStr.match(/\d{4}/g);
if (!yearsMatch || yearsMatch.length === 0) return rangosStr;
const years = [...new Set(yearsMatch.map(y => parseInt(y)))].sort((a, b) => a - b);
if (years.length === 1) return String(years[0]);
const ranges = [];
let rangeStart = years[0];
let rangeEnd = years[0];
for (let i = 1; i < years.length; i++) {
if (years[i] === rangeEnd + 1) {
rangeEnd = years[i];
} else {
ranges.push(rangeStart === rangeEnd ? String(rangeStart) : `${rangeStart}-${rangeEnd}`);
rangeStart = years[i];
rangeEnd = years[i];
}
}
ranges.push(rangeStart === rangeEnd ? String(rangeStart) : `${rangeStart}-${rangeEnd}`);
return ranges.join(', ');
}
function getNivelLabel(nivel) {
const labels = { grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' };
return labels[nivel] || nivel;
......@@ -123,6 +153,31 @@
}
// ══════════════════════════════════════════════════════════════
// 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
......@@ -131,10 +186,14 @@
// Cargar datos según selección
async function loadData() {
const loadId = Date.now();
console.log(`[CLIENT] loadData started (${loadId})`);
const startTime = performance.now();
loading = true;
if (selectedEntity) {
// Cargar desde vista_objeto_entidad
const queryStart = performance.now();
const { data, error } = await supabase
.schema('ppto')
.from('vista_objeto_entidad')
......@@ -144,6 +203,7 @@
.eq('entidad', selectedEntity.entidad)
.order('gestion');
console.log(`[CLIENT] Query vista_objeto_entidad: ${(performance.now() - queryStart).toFixed(0)}ms`);
if (data) {
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion > 0);
......@@ -151,6 +211,7 @@
}
} else {
// Cargar desde vista_objeto_estado (Todo el Estado)
const queryStart = performance.now();
const { data, error } = await supabase
.schema('ppto')
.from('vista_objeto_estado')
......@@ -158,6 +219,7 @@
.eq('objeto', objetoCodigo)
.order('gestion');
console.log(`[CLIENT] Query vista_objeto_estado: ${(performance.now() - queryStart).toFixed(0)}ms`);
if (data) {
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion > 0);
......@@ -175,10 +237,12 @@
}
loading = false;
console.log(`[CLIENT] loadData total: ${(performance.now() - startTime).toFixed(0)}ms (${loadId})`);
}
// Cargar lista de entidades disponibles para este objeto
async function loadEntidades() {
const startTime = performance.now();
const { data, error } = await supabase
.schema('ppto')
.from('entidades_por_objeto')
......@@ -190,6 +254,7 @@
if (data) {
entidades = data;
}
console.log(`[CLIENT] loadEntidades: ${(performance.now() - startTime).toFixed(0)}ms`);
}
// ══════════════════════════════════════════════════════════════
......@@ -574,6 +639,7 @@
// MOUNT
// ══════════════════════════════════════════════════════════════
onMount(async () => {
const mountStart = performance.now();
setTimeout(() => { mounted = true; }, 50);
// Leer estado del tema
......@@ -589,7 +655,10 @@
});
// Cargar datos
const loadStart = performance.now();
await Promise.all([loadData(), loadEntidades()]);
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
const handleClickOutside = (e) => {
......@@ -606,9 +675,24 @@
};
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);
};
});
......@@ -660,10 +744,38 @@
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</svelte:head>
<div class="page" class:mounted class:tema-claro={!isDark}>
<!-- 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}
<!-- Header compacto -->
<header>
<!-- 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}
......@@ -690,7 +802,7 @@
<div class="meta">
<span class="badge">{objeto.nivel}</span>
<span class="years">{objeto.años}</span>
<span class="years">{formatYearsAsRanges(objeto.años)}</span>
</div>
</header>
......@@ -705,7 +817,7 @@
<h4>Variaciones históricas</h4>
{#each objeto.variaciones as v}
<div class="variacion">
<span class="variacion-rango">{v.rango}</span>
<span class="variacion-rango">{formatYearsAsRanges(v.rango)}</span>
<p>{v.texto}</p>
</div>
{/each}
......@@ -1112,25 +1224,56 @@
{/if}
</main>
</div>
</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 -->
<style>
.page {
/* Split container */
.split-container {
display: flex;
min-height: 100vh;
background: #0A0A0A;
color: #F5F0E8;
font-family: 'Instrument Sans', -apple-system, sans-serif;
background: var(--theme-body);
}
.split-left {
flex-shrink: 0;
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
border-right: 1px solid var(--theme-borde);
}
.split-right {
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;
}
.mounted { opacity: 1; }
.split-right.mounted { opacity: 1; }
/* Header */
header {
padding: 1rem 2rem;
border-bottom: 1px solid #1E1E1C;
border-bottom: 1px solid var(--theme-borde);
}
.breadcrumb {
......@@ -1143,14 +1286,14 @@
.breadcrumb a {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: #5A5650;
color: var(--theme-texto);
text-decoration: none;
transition: color 0.2s;
}
.breadcrumb a:hover { color: #F5F0E8; }
.breadcrumb a:hover { color: var(--theme-titulo); }
.breadcrumb-sep {
color: #3A3A38;
color: var(--theme-borde);
font-size: 0.75rem;
}
......@@ -1179,15 +1322,15 @@
.codigo {
font-family: 'DM Mono', monospace;
font-size: 1.5rem;
color: #5A5650;
color: var(--theme-texto);
font-weight: 400;
}
h1 {
font-family: 'DM Serif Display', Georgia, serif;
font-family: var(--font-display), Georgia, serif;
font-size: 1.5rem;
font-weight: 400;
color: #F5F0E8;
color: var(--theme-titulo);
margin: 0;
display: inline-flex;
align-items: center;
......@@ -1198,7 +1341,7 @@
background: transparent;
border: none;
padding: 0;
color: #5A5650;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
......@@ -1207,7 +1350,7 @@
vertical-align: middle;
}
.help-icon:hover, .help-icon.active {
color: #E8C547;
color: var(--theme-accent);
}
.meta {
......@@ -1223,22 +1366,137 @@
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.25rem 0.5rem;
background: #1A1A18;
border: 1px solid #2E2E2C;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 4px;
color: #8A8578;
color: var(--theme-texto);
}
.years {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: #5A5650;
color: var(--theme-texto);
}
/* Split View - Header y controles */
.split-header {
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);
}
.split-close {
padding: 0.25rem;
border: none;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.split-close:hover {
background: var(--theme-borde);
color: var(--theme-titulo);
}
.split-iframe {
flex: 1;
width: 100%;
border: none;
background: var(--theme-body);
}
.split-resizer {
width: 8px;
background: var(--theme-borde);
cursor: col-resize;
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;
}
.split-resizer:hover {
background: var(--theme-accent);
}
.split-resizer:hover::after {
opacity: 0.8;
height: 64px;
}
/* Botón para abrir/cerrar split view */
.split-toggle {
position: fixed;
left: 1rem;
bottom: 1.5rem;
display: none;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
color: var(--theme-texto);
font-size: 0.75rem;
cursor: pointer;
z-index: 60;
transition: all 0.2s, left 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.split-active .split-toggle {
/* Se ajusta automáticamente con margin-left del contenedor */
}
@media (min-width: 1024px) {
.split-toggle {
display: flex;
}
}
.split-toggle:hover {
background: var(--theme-fill);
color: var(--theme-titulo);
border-color: var(--theme-accent);
}
.split-toggle.active {
background: var(--theme-accent);
color: white;
border-color: var(--theme-accent);
}
.split-toggle-label {
font-weight: 500;
}
/* Info panel */
.info-panel {
background: #111110;
border-bottom: 1px solid #1E1E1C;
background: var(--theme-fill);
border-bottom: 1px solid var(--theme-borde);
padding: 1.5rem 2rem;
}
......@@ -1247,27 +1505,27 @@
font-size: 0.625rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #5A5650;
color: var(--theme-texto);
margin: 0 0 0.5rem 0;
}
.info-content p {
font-size: 0.875rem;
line-height: 1.6;
color: #8A8578;
color: var(--theme-texto);
margin: 0 0 1.25rem 0;
}
.variacion {
padding-left: 1rem;
border-left: 2px solid #2E2E2C;
border-left: 2px solid var(--theme-borde);
margin-bottom: 1rem;
}
.variacion-rango {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
color: #5A5650;
color: var(--theme-texto);
}
.variacion p {
......@@ -1294,23 +1552,23 @@
}
.hijo-link:hover {
background: rgba(255, 255, 255, 0.05);
background: var(--theme-fill);
}
.hijo-codigo {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
color: #5A5650;
color: var(--theme-texto);
min-width: 3rem;
}
.hijo-nombre {
font-size: 0.813rem;
color: #B0A89C;
color: var(--theme-texto);
}
.hijo-link:hover .hijo-nombre {
color: #F5F0E8;
color: var(--theme-titulo);
}
/* Main */
......@@ -1330,9 +1588,8 @@
}
.interaction-hint {
font-size: 0.688rem;
color: #5A5650;
font-style: italic;
font-size: 0.75rem;
color: var(--theme-texto);
margin-left: auto;
}
......@@ -1355,7 +1612,7 @@
.selector-label {
font-size: 0.813rem;
color: #5A5650;
color: var(--theme-texto);
}
.selector-btn {
......@@ -1365,18 +1622,18 @@
padding: 0.5rem 1rem;
background: transparent;
border: none;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
box-shadow: inset 0 0 0 1px var(--theme-borde);
border-radius: 8px;
color: #F5F0E8;
color: var(--theme-titulo);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.selector-btn:hover {
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.15);
box-shadow: inset 0 0 0 1px var(--theme-accent);
}
.selector-btn svg {
color: #5A5650;
color: var(--theme-texto);
}
.relative {
......@@ -1391,8 +1648,8 @@
width: min(500px, 90vw);
max-height: 360px;
overflow: hidden;
background: #1A1A18;
border: 1px solid #2E2E2C;
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;
......@@ -1400,24 +1657,24 @@
.dropdown-search {
padding: 0.5rem;
border-bottom: 1px solid #2E2E2C;
border-bottom: 1px solid var(--theme-borde);
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
background: #111110;
border: 1px solid #2E2E2C;
background: var(--theme-fill);
border: 1px solid var(--theme-borde);
border-radius: 6px;
color: #F5F0E8;
color: var(--theme-titulo);
font-size: 0.875rem;
}
.search-input:focus {
outline: none;
border-color: #E8C547;
border-color: var(--theme-accent);
}
.search-input::placeholder {
color: #5A5650;
color: var(--theme-texto);
}
.dropdown-item {
......@@ -1428,7 +1685,7 @@
padding: 0.625rem 0.75rem;
background: transparent;
border: none;
color: #F5F0E8;
color: var(--theme-titulo);
font-size: 0.813rem;
text-align: left;
cursor: pointer;
......@@ -1449,7 +1706,7 @@
.entity-code {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
color: #5A5650;
color: var(--theme-texto);
min-width: 2.5rem;
}
......@@ -1499,7 +1756,7 @@
.chart-title {
font-size: 0.75rem;
color: #5A5650;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.1em;
}
......@@ -1507,7 +1764,7 @@
.chart-period {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
color: #B0A89C;
color: var(--theme-texto);
font-variant-numeric: tabular-nums;
min-width: 5rem;
text-align: right;
......@@ -1522,7 +1779,7 @@
overflow-x: auto;
overflow-y: visible;
scrollbar-width: thin;
scrollbar-color: #2E2E2C transparent;
scrollbar-color: var(--theme-borde) transparent;
}
.chart-wrapper-scroll::-webkit-scrollbar {
......@@ -1534,7 +1791,7 @@
}
.chart-wrapper-scroll::-webkit-scrollbar-thumb {
background: #2E2E2C;
background: var(--theme-borde);
border-radius: 3px;
}
......@@ -1542,7 +1799,7 @@
position: sticky;
left: 0;
z-index: 10;
background: #0A0A0A;
background: var(--theme-body);
padding-right: 0.5rem;
margin-right: -0.5rem;
}
......@@ -1567,7 +1824,7 @@
.y-label {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: #5A5650;
color: var(--theme-texto);
}
.chart {
......@@ -1577,7 +1834,7 @@
gap: 4px;
height: 160px;
padding: 0 0.5rem;
border-left: 1px solid #2E2E2C;
border-left: 1px solid var(--theme-borde);
}
.bar-container {
......@@ -1599,14 +1856,14 @@
}
.bar.hovered {
background: #E8C547;
background: var(--theme-accent);
transform: scaleX(1.1);
}
.bar-label {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: #B0A89C;
color: var(--theme-texto);
margin-top: 0.5rem;
opacity: 0;
transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1), color 0.25s ease;
......@@ -1635,7 +1892,7 @@
font-family: 'DM Mono', monospace;
font-size: 1.125rem;
font-weight: 500;
color: #F5F0E8;
color: var(--theme-titulo);
margin-bottom: 0;
transition: opacity 0.2s ease;
font-variant-numeric: tabular-nums;
......@@ -1644,7 +1901,7 @@
.kpi-label {
font-size: 0.5625rem;
color: #5A5650;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.03em;
line-height: 1.3;
......@@ -1666,7 +1923,7 @@
.top-section h3 {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: #5A5650;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0;
......@@ -1675,7 +1932,7 @@
.top-context {
font-family: 'DM Mono', monospace;
font-size: 0.688rem;
color: #5A5650;
color: var(--theme-texto);
font-variant-numeric: tabular-nums;
}
......@@ -1703,7 +1960,7 @@
.top-rank {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
color: #5A5650;
color: var(--theme-texto);
width: 24px;
}
......@@ -1714,32 +1971,32 @@
.top-name {
display: block;
font-size: 0.8125rem;
color: #F5F0E8;
color: var(--theme-titulo);
margin-bottom: 0.375rem;
}
.top-bar-bg {
height: 4px;
background: #1E1E1C;
background: var(--theme-surface);
border-radius: 2px;
overflow: hidden;
}
.top-bar {
height: 100%;
background: #C9A751;
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: #E8C547;
background: var(--theme-accent);
}
.top-pct {
font-family: 'DM Mono', monospace;
font-size: 0.875rem;
color: #B0A89C;
color: var(--theme-texto);
min-width: 52px;
width: 52px;
text-align: right;
......@@ -1749,10 +2006,10 @@
.see-all {
background: transparent;
border: 1px solid #2E2E2C;
border: 1px solid var(--theme-borde);
border-radius: 8px;
padding: 0.75rem 1rem;
color: #8A8578;
color: var(--theme-texto);
font-size: 0.813rem;
cursor: pointer;
transition: all 0.2s;
......@@ -1760,8 +2017,8 @@
margin-top: 1rem;
}
.see-all:hover {
border-color: #E8C547;
color: #E8C547;
border-color: var(--theme-accent);
color: var(--theme-accent);
}
/* Vista Toggle */
......@@ -1778,7 +2035,7 @@
padding: 0.5rem 1.25rem;
font-size: 0.813rem;
font-weight: 500;
color: #5A5650;
color: var(--theme-texto);
background: transparent;
border: none;
border-radius: 7px;
......@@ -1787,12 +2044,12 @@
}
.toggle-btn:hover {
color: #8A8578;
color: var(--theme-texto);
}
.toggle-btn.active {
background: #F5F0E8;
color: #0A0A0A;
background: var(--theme-titulo);
color: var(--theme-body);
}
/* Comparador inline - ajustado para 100vh */
......@@ -1823,20 +2080,20 @@
justify-content: center;
min-height: 200px;
background: rgba(255,255,255,0.02);
border: 1px dashed #2E2E2C;
border: 1px dashed var(--theme-borde);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.comparador-placeholder p {
color: #5A5650;
color: var(--theme-texto);
font-size: 0.875rem;
margin: 0;
}
.comparador-placeholder strong {
color: #8A8578;
color: var(--theme-texto);
}
.rango-selector {
......@@ -1848,7 +2105,7 @@
.rango-label {
font-size: 0.75rem;
color: #5A5650;
color: var(--theme-texto);
}
.rango-options {
......@@ -1860,7 +2117,7 @@
padding: 0.375rem 0.75rem;
font-size: 0.6875rem;
font-family: 'DM Mono', monospace;
color: #5A5650;
color: var(--theme-texto);
background: transparent;
border: none;
border-radius: 6px;
......@@ -1869,12 +2126,12 @@
}
.rango-btn:hover {
color: #8A8578;
color: var(--theme-texto);
background: rgba(255,255,255,0.05);
}
.rango-btn.active {
color: #F5F0E8;
color: var(--theme-titulo);
background: rgba(255,255,255,0.1);
}
......@@ -1904,20 +2161,20 @@
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
border-radius: 8px;
padding: 0.5rem 0.75rem;
color: #F5F0E8;
color: var(--theme-titulo);
font-size: 0.813rem;
font-family: 'Instrument Sans', sans-serif;
cursor: pointer;
}
.selector-group select:focus {
outline: none;
border-color: #E8C547;
border-color: var(--theme-accent);
}
.vs {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: #5A5650;
color: var(--theme-texto);
flex-shrink: 0;
}
......@@ -1973,7 +2230,7 @@
}
.bar-label.highlight {
color: #F5F0E8;
color: var(--theme-titulo);
font-weight: 500;
transition: color 0.25s ease;
}
......@@ -1991,7 +2248,7 @@
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: #8A8578;
color: var(--theme-texto);
}
.legend-dot {
......@@ -2013,7 +2270,7 @@
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid #2E2E2C;
border-bottom: 1px solid var(--theme-borde);
margin-bottom: 0.375rem;
}
......@@ -2036,7 +2293,7 @@
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
font-weight: 500;
color: #8A8578;
color: var(--theme-texto);
display: flex;
align-items: center;
text-transform: uppercase;
......@@ -2047,7 +2304,7 @@
font-family: 'DM Mono', monospace;
font-size: 0.9375rem;
font-weight: 500;
color: #F5F0E8;
color: var(--theme-titulo);
text-align: center;
font-variant-numeric: tabular-nums;
transition: opacity 0.2s ease;
......@@ -2059,20 +2316,20 @@
padding: 1rem 1.5rem;
background: linear-gradient(90deg, rgba(232,197,71,0.05), rgba(74,139,110,0.05));
border-radius: 10px;
border: 1px solid #2E2E2C;
border: 1px solid var(--theme-borde);
font-size: 0.875rem;
color: #8A8578;
color: var(--theme-texto);
line-height: 1.6;
}
.comparador-insight strong {
color: #F5F0E8;
color: var(--theme-titulo);
}
.insight-year {
font-family: 'DM Mono', monospace;
font-size: 0.813rem;
color: #E8C547;
color: var(--theme-accent);
}
/* Responsive */
......@@ -2147,335 +2404,5 @@
}
}
/* ========== TEMA CLARO ========== */
.tema-claro {
background: #FAFAF8;
color: #1A1A18;
}
.tema-claro header {
border-bottom-color: #E5E5E0;
}
.tema-claro .breadcrumb a {
color: #8A8578;
}
.tema-claro .breadcrumb a:hover {
color: #1A1A18;
}
.tema-claro .breadcrumb-sep {
color: #C5C5C0;
}
.tema-claro .codigo {
color: #8A8578;
}
.tema-claro h1 {
color: #1A1A18;
}
.tema-claro .help-icon {
color: #8A8578;
}
.tema-claro .help-icon:hover,
.tema-claro .help-icon.active {
color: #C9A227;
}
.tema-claro .badge {
background: #F0F0EC;
border-color: #E5E5E0;
color: #5A5650;
}
.tema-claro .years {
color: #8A8578;
}
.tema-claro .info-panel {
background: #F5F5F3;
border-bottom-color: #E5E5E0;
}
.tema-claro .info-content h4 {
color: #8A8578;
}
.tema-claro .info-content p {
color: #5A5650;
}
.tema-claro .variacion {
border-left-color: #D5D5D0;
}
.tema-claro .variacion-rango {
color: #8A8578;
}
.tema-claro .hijo-link:hover {
background: rgba(0, 0, 0, 0.03);
}
.tema-claro .hijo-codigo {
color: #8A8578;
}
.tema-claro .hijo-nombre {
color: #5A5650;
}
.tema-claro .hijo-link:hover .hijo-nombre {
color: #1A1A18;
}
.tema-claro .vista-toggle {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.tema-claro .toggle-btn {
color: #8A8578;
}
.tema-claro .toggle-btn:hover {
color: #5A5650;
}
.tema-claro .toggle-btn.active {
background: #1A1A18;
color: #FAFAF8;
}
.tema-claro .entity-selector .selector-label {
color: #8A8578;
}
.tema-claro .interaction-hint {
color: #8A8578;
}
.tema-claro .selector-btn {
background: transparent;
border: none;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
color: #1A1A18;
}
.tema-claro .selector-btn:hover {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.15);
}
.tema-claro .selector-btn svg {
color: #8A8578;
}
.tema-claro .dropdown-panel {
background: #FAFAF8;
border-color: #E5E5E0;
}
.tema-claro .dropdown-search {
border-bottom-color: #E5E5E0;
}
.tema-claro .search-input {
background: #F5F5F3;
border-color: #E5E5E0;
color: #1A1A18;
}
.tema-claro .search-input:focus {
border-color: #C9A227;
}
.tema-claro .search-input::placeholder {
color: #8A8578;
}
.tema-claro .dropdown-item {
color: #1A1A18;
}
.tema-claro .dropdown-item:hover {
background: rgba(0,0,0,0.03);
}
.tema-claro .dropdown-item.active {
background: rgba(201,162,39,0.12);
}
.tema-claro .entity-code {
color: #8A8578;
}
.tema-claro .chart-title {
color: #8A8578;
}
.tema-claro .chart-period {
color: #333333;
}
.tema-claro .y-label {
color: #8A8578;
}
.tema-claro .y-axis-sticky {
background: #FAFAF8;
}
.tema-claro .chart-wrapper-scroll::-webkit-scrollbar-thumb {
background: #D5D5D0;
}
.tema-claro .chart {
border-left-color: #E5E5E0;
}
.tema-claro .bar-container .bar {
background: #2a9d8f;
}
.tema-claro .bar-container .bar.hovered {
background: #C9A751;
}
/* Las barras de comparación mantienen su --bar-color */
.tema-claro .bar-a,
.tema-claro .bar-b {
background: var(--bar-color);
}
.tema-claro .bar-label {
color: #333333;
}
.tema-claro .kpis {
background: transparent;
}
.tema-claro .kpi {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.tema-claro .kpi-value {
color: #1A1A18;
}
.tema-claro .kpi-label {
color: #8A8578;
}
.tema-claro .top-section h3 {
color: #8A8578;
}
.tema-claro .top-context {
color: #8A8578;
}
.tema-claro .top-item {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.tema-claro .top-rank {
color: #8A8578;
}
.tema-claro .top-name {
color: #1A1A18;
}
.tema-claro .top-bar-bg {
background: #F0F0EC;
}
.tema-claro .top-bar {
background: #C9A751;
}
.tema-claro .top-item:hover {
box-shadow: inset 0 0 0 1px rgba(201, 167, 81, 0.5);
}
.tema-claro .top-item:hover .top-bar {
background: #B8963F;
}
.tema-claro .top-pct {
color: #333333;
}
/* Comparador - tema claro */
.tema-claro .comparador-selectors .selector-group select {
background: transparent;
border: none;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
color: #1A1A18;
}
.tema-claro .comparador-selectors .selector-group select:focus {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.15);
outline: none;
}
.tema-claro .comparador-placeholder {
background: rgba(0,0,0,0.02);
border-color: #E5E5E0;
}
.tema-claro .comparador-placeholder p {
color: #8A8578;
}
.tema-claro .comparador-placeholder strong {
color: #5A5650;
}
.tema-claro .rango-label {
color: #8A8578;
}
.tema-claro .rango-btn {
color: #8A8578;
}
.tema-claro .rango-btn:hover {
color: #5A5650;
background: rgba(0,0,0,0.05);
}
.tema-claro .rango-btn.active {
color: #1A1A18;
background: rgba(0,0,0,0.08);
}
.tema-claro .vs {
color: #8A8578;
}
.tema-claro .chart-legend .legend-item {
color: #5A5650;
}
.tema-claro .kpis-tabla {
background: transparent;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.tema-claro .tabla-header {
border-bottom-color: #E5E5E0;
}
.tema-claro .tabla-label {
color: #5A5650;
}
.tema-claro .tabla-val {
color: #1A1A18;
}
.tema-claro .bar-group.active {
background: rgba(42, 157, 143, 0.12);
}
.tema-claro .bar-label.highlight {
color: #1A1A18;
}
/* Tema claro: las variables CSS cambian automáticamente via :root.dark */
</style>
......