BarChart.svelte 7.94 KB
<script>
  import { scaleLinear, scaleBand } from 'd3-scale';

  let {
    data = [],
    hoveredYear = $bindable(null),
    lockedYear = $bindable(null),
    height = 220,
    fill = false,
    marginTop = 20,
    marginRight = 10,
    marginBottom = 30,
    marginLeft = 50
  } = $props();

  // El año activo es el fijado o el hover
  let activeYear = $derived(lockedYear ?? hoveredYear);

  function handleBarClick(año) {
    if (lockedYear === año) {
      lockedYear = null; // desfijar
    } else {
      lockedYear = año; // fijar
    }
  }

  // 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(Math.max(effectiveHeight - marginTop - marginBottom, 50));

  // Máximo del eje Y (con guard para data vacía o valores undefined)
  let maxPerCapita = $derived.by(() => {
    if (data.length === 0) return 1;
    const vals = data.map(d => typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0);
    const max = Math.max(...vals);
    const min = Math.min(...vals.filter(v => v > 0));
    // Si max es 0, usar 1 como fallback
    if (max <= 0) return 1;
    // Agregar 10% de margen arriba para que la barra más alta no toque el techo
    return max * 1.1;
  });

  // Escalas D3
  let xScale = $derived(
    scaleBand()
      .domain(data.map(d => d.año))
      .range([0, 100]) // porcentaje
      .padding(0.6)
  );

  // Para CSS bottom positioning: 0 → 0%, max → 100%
  let yScale = $derived(
    scaleLinear()
      .domain([0, maxPerCapita])
      .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'}; min-height: {height}px">
  {#if data.length > 0 && chartHeight > 0}
  <!-- 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" class:has-active={!!lockedYear} onmouseleave={() => { if (!lockedYear) hoveredYear = null; }} role="group">
      {#each data as d}
        {@const safeVal = typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0}
        {@const barPct = chartHeight > 0 ? (yScale(safeVal) / chartHeight) * 100 : 0}
        <div
          class="bar-container"
          onmouseenter={() => { if (!lockedYear) hoveredYear = d.año; }}
          onclick={() => handleBarClick(d.año)}
          role="button"
          tabindex="0"
        >
          <div
            class="bar"
            class:hovered={activeYear === d.año}
            class:locked={lockedYear === d.año}
            style="height: {barPct}%; {safeVal > 0 ? 'min-height: 3px;' : ''}"
          ></div>
        </div>
      {/each}
    </div>

    <!-- Hint -->
    <div class="chart-hint">
      {#if lockedYear}
        <span class="hint-locked">
          {lockedYear} fijado
          <button class="hint-unlock" onclick={() => lockedYear = null}>soltar</button>
        </span>
      {:else if activeYear}
        <span class="hint-hover hint-desktop-only">Click para fijar {activeYear}</span>
      {:else}
        <span class="hint-idle hint-desktop-only">Pasa el cursor sobre las barras</span>
        <span class="hint-idle hint-mobile-only">Toca una barra para explorar</span>
      {/if}
    </div>
  </div>

  <!-- Eje X -->
  <div class="x-axis" style="height: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px">
    {#each data as d, i}
      <span
        class="x-label visible"
        class:hovered={activeYear === d.año}
      >
        {String(d.año).slice(-2)}
      </span>
    {/each}
  </div>
  {:else}
  <div class="chart-empty">
    <span>Sin datos</span>
  </div>
  {/if}
</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: 4px;
  }

  .bar-container {
    flex: 1;
    height: 100%;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    cursor: pointer;
  }

  .bar {
    width: 80%;
    background: #c4897d;
    border-radius: 4px 4px 0 0;
    transition: opacity 0.1s ease;
    min-height: 2px;
  }

  :global(html.dark) .bar {
    background: rgba(107, 159, 212, 0.85);
  }

  /* Fade en hover */
  .bars:hover .bar {
    opacity: 0.35;
  }
  .bars:hover .bar.hovered {
    opacity: 1;
  }

  /* Fade permanente cuando hay año fijado */
  .bars.has-active .bar {
    opacity: 0.25;
  }
  .bars.has-active .bar.locked {
    opacity: 1;
    box-shadow: 0 0 0 2px var(--theme-accent, #C9A751);
  }

  /* Chart hint */
  .chart-hint {
    position: absolute;
    top: -2px;
    right: 0;
    font-family: 'Qanelas', var(--font-sans);
    font-size: 0.625rem;
    color: var(--theme-texto);
    opacity: 0.5;
  }
  .hint-locked {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    color: var(--theme-accent, #C9A751);
    opacity: 1;
  }
  .hint-unlock {
    background: none;
    border: none;
    border-bottom: 1px dotted currentColor;
    color: inherit;
    font: inherit;
    cursor: pointer;
    padding: 0;
    opacity: 0.7;
    transition: opacity 0.15s;
  }
  .hint-unlock:hover {
    opacity: 1;
  }
  .hint-mobile-only { display: none; }
  .hint-desktop-only { display: inline; }
  @media (max-width: 768px) {
    .hint-mobile-only { display: inline; }
    .hint-desktop-only { display: none; }
  }

  .x-axis {
    position: absolute;
    bottom: 0;
    display: flex;
    align-items: center;
    gap: 4px;
  }

  .x-label {
    flex: 1;
    text-align: center;
    font-family: 'DM Mono', monospace;
    font-size: 0.5625rem;
    color: var(--theme-texto);
    opacity: 0;
    transition: opacity 0.2s ease;
  }

  .x-label.visible {
    opacity: 0.5;
  }

  .x-label.hovered {
    opacity: 1;
    color: var(--theme-titulo);
    font-weight: 600;
  }

  .chart-empty {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--theme-texto);
    opacity: 0.5;
    font-size: 0.875rem;
  }
</style>