BarChart.svelte 5.41 KB
<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(Math.max(effectiveHeight - marginTop - marginBottom, 50));

  // Máximo del eje Y (con guard para data vacía o valores undefined)
  let maxPerCapita = $derived(
    data.length > 0 ? Math.max(...data.map(d => d.perCapita || 0), 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" 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}
      <span
        class="x-label visible"
        class:hovered={hoveredYear === 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: default;
  }

  .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);
  }

  .bars:hover .bar {
    opacity: 0.35;
  }

  .bars:hover .bar.hovered {
    opacity: 1;
  }

  .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>