Rafael Lopez

template obejtos agregado

This diff is collapsed. Click to expand it.
<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" />
......
This diff is collapsed. Click to expand it.