Aquel que me haya seguido desde 2017, sabrá que me gusta mucho la solución de AnyChart, pero que ésta es de pago, aunque los que trabajamos con PHPRunner, la licencia de PHPRunner incluye una licencia de AnyChart para integrar gráficos en sus desarrollos.
Este producto ApexCharts, es también un producto de muchas posibilidades y de excelente calidad, que dispone de una licencia Community, por lo que podremos utilizarlo en casi todas las posibilidades.
Aparte de analizar los tipos de gráficos, sus posibilidades, etc., se quiere probar su funcionamiento en Svelte 5 con Runas y su integración con TailWind CSS y Daisy UI.
Objetivo
Construir un ejemplo de aplicativo donde integremos ApexCharts, TailWind CSS y Daisy UI, y analizar las posibilidades de construcción de DashBoard.
DEMO: https://fhumanes.com/apexchart-svelte/

Solución Técnica
Prácticamente, ya os he indicado los productos que he utilizado para construir el ejemplo:
-
- Front-End
- Svelte 5 (javaScript)
- TailWind CSS
- Daisy UI
- ApexCharts
- Back-End
- PHP
- Slim 4
- Mysql
- Front-End
Son los productos que utilizo en casi todos los ejemplos, por lo que si sois seguidores, no tendréis problemas en su uso. Recordar que para instalar el ejemplo del Front-End se ejecuta el comando «npm install» y ya lo tendríais funcionando en vuestro PC.
En este caso, la parte del Back-End la he dejado para que la instaléis con composer install, en el kit que os dejo. Esto lo hago para que los kits sean más pequeños y para que tengáis instalado las últimas versiones de los productos requeridos.
La versión de Back-End es una evolución/mejora del código que estaba utilizando y si trabajáis con este tipo de solución, os aconsejo que la reviséis.
Cuando haces un ejemplo de un producto de gráficos, nunca puede ser completo, pues las posibilidades de tipos de gráficos son casi infinitas, El que siempre intento probar es el de semi-donuts (el hemiciclo de unas cortes), porque no suele estar dentro del catálogo de soluciones, pero si el producto es maduro, a través de las soluciones de los gráficos de Donuts, se puede hacer. En este caso, así ha sido.
Aplicación de Front-End
He estructurado el proyecto en estos directorios:
- «src/lib» .- Es donde defino las funciones exclusiva de JavaScript. En este caso, está el API para el acceso al servidor.
- «src/components/ApexChart» .- Es donde está el código de los gráficos que tiene el ejemplo.
- «src/components/DataGrid» .- en el ejemplo no se usa. Está ahí, porque quería incluir un DataGrid con gráficos, pero pensé que iba a complicaros demasiado el ejemplo y he dejado esa parte para otro ejemplo.
- «src/pages» .- Son las páginas que se llaman desde el menú que está en «App.svelte»
Os muestro algunos de los ficheros más representativos:
<script>
import Router from 'svelte-spa-router';
// import active from 'svelte-spa-router/active';
import Donuts from './pages/DonutsPage.svelte';
import BarEscanos from './pages/BarEscanosPage.svelte';
import Evolutivos from './pages/EvolutivosPage.svelte';
import Dashboard from './pages/DashboardPage.svelte';
// import Test from './pages/testPage.svelte';
const routes = {
'/': Dashboard,
'/dashboard': Dashboard,
'/donuts': Donuts,
'/barescanos': BarEscanos,
'/evolutivos': Evolutivos,
// '/test': Test,
};
let current = location.hash; // location.hash; ejemplo: '#/donuts'
// actualizar cuando cambie el hash
window.addEventListener("hashchange", () => {
current = location.hash;
});
console.log("Current route:", current);
</script>
<!-- NAVBAR DataGrid -->
<div class="navbar bg-neutral text-neutral-content px-2 lg:px-4">
<div class="flex-1">
<a href="#/" class="text-xl font-bold">Elecciones Comunidad de Madrid</a>
</div>
<div class="flex gap-4">
<a
href="#/dashboard"
class="btn btn-sm text-neutral-content hover:text-neutral {current === '#/dashboard' || current === '#/' || current === '' ? 'btn-primary' : 'btn-ghost'}"
>
DashBoard
</a>
<a
href="#/donuts"
class="btn btn-sm text-neutral-content hover:text-neutral {current === '#/donuts' ? 'btn-primary' : 'btn-ghost'}"
>
Donuts
</a>
<a
href="#/barescanos"
class="btn btn-sm text-neutral-content hover:text-neutral {current === '#/barescanos' ? 'btn-primary' : 'btn-ghost'}"
>
Escaños
</a>
<a
href="#/evolutivos"
class="btn btn-sm text-neutral-content hover:text-neutral {current === '#/evolutivos' ? 'btn-primary' : 'btn-ghost'}"
>
Evolutivos
</a>
<!--
<a
href="#/test"
class="btn btn-sm text-neutral-content hover:text-neutral {current === '#/test' ? 'btn-primary' : 'btn-ghost'}"
>
Test
</a>
-->
</div>
</div>
<!-- CONTENIDO PRINCIPAL -->
<div class="p-1 bg-gray-50 min-h-screen">
<Router {routes} />
</div>
<script>
import { onMount } from "svelte";
import api from "../lib/api.js";
// Gráficos de escaños
import BarEscanos from "../components/ApexChart/BarEscanos.svelte";
import SemiDonutEscanos from "../components/ApexChart/SemiDonutEscanos.svelte";
// Gráficos evolutivos
import EvolutivoVotos from "../components/ApexChart/EvolutivoVotos.svelte";
import EvolutivoPorcentaje from "../components/ApexChart/EvolutivoPorcentaje.svelte";
import EvolutivoEscanos from "../components/ApexChart/EvolutivoEscanos.svelte";
// Estado general del Dashboard
let convocatorias = $state([]);
let selectedConvocatoria = $state(null);
// Datos para los gráficos de escaños
let labels = $state([]);
let colors = $state([]);
let escanos = $state([]);
let votos = $state([]);
let porcentajes = $state([]);
// Datos para los evolutivos
let datosEvo = $state([]);
let evoConvocatorias = $state([]);
let candidaturas = $state([]);
let matriz = $state({});
let coloresCandidaturas = $state({});
// Tab de evolutivos
let tab = $state("votos");
// ---------------------------------------------------------
// 🔵 CARGA DE ESCANOS POR CONVOCATORIA (como BarEscanosPage)
// ---------------------------------------------------------
async function loadConvocatorias() {
const res = await api.get("/convocatorias");
convocatorias = res.data.filter(c => Number(c.idConvocatoria) > 0);
if (convocatorias.length > 0) {
selectedConvocatoria = convocatorias[0].idConvocatoria;
await loadEscanos();
}
}
async function loadEscanos() {
const id = Number(selectedConvocatoria);
if (!id || id <= 0) return;
const res = await api.get(`/escanos/${id}`);
const data = res.data;
const ordenado = data
.filter(d => d.Escanos > 0)
.sort((a, b) => b.Escanos - a.Escanos);
labels = ordenado.map(d => d.Codigo);
colors = ordenado.map(d => d.Color);
escanos = ordenado.map(d => d.Escanos);
votos = ordenado.map(d => d.Votos);
porcentajes = ordenado.map(d => d.PorcVotos);
}
// ---------------------------------------------------------
// 🔵 CARGA DE EVOLUTIVOS (como EvolutivosPage)
// ---------------------------------------------------------
async function loadEvolutivos() {
const res = await api.get("/escanos_evolucion");
datosEvo = res.data;
// Convocatorias únicas
evoConvocatorias = [...new Set(datosEvo.map(d => d.Convocatoria))]
.sort((a, b) => a.localeCompare(b));
// Candidaturas únicas
candidaturas = [...new Set(datosEvo.map(d => d.Candidatura))]
.sort((a, b) => a.localeCompare(b));
// Matriz completa
const m = {};
candidaturas.forEach(cand => {
m[cand] = {};
evoConvocatorias.forEach(conv => {
m[cand][conv] = null;
});
});
datosEvo.forEach(d => {
m[d.Candidatura][d.Convocatoria] = {
votos: d.Votos,
porc: d.PorcVotos,
escanos: d.Escanos === 0 ? null : d.Escanos
};
});
matriz = m;
}
async function loadColoresCandidaturas() {
const res = await api.get("/candidaturas");
coloresCandidaturas = Object.fromEntries(
res.data.map(d => [d.Codigo, d.Color])
);
}
// ---------------------------------------------------------
// 🔵 INICIALIZACIÓN DEL DASHBOARD
// ---------------------------------------------------------
onMount(async () => {
await loadConvocatorias();
await loadColoresCandidaturas();
await loadEvolutivos();
});
</script>
<div class="p-1 lg:p-4 space-y-6 bg-base-200 min-h-screen">
<!-- Selector de convocatoria -->
<h2 class="text-xl font-bold mb-4">Dashboard Electoral</h2>
<fieldset class="fieldset space-y-0 mb-2">
<legend class="fieldset-legend mb-0">Convocatoria</legend>
<select
class="select select-bordered max-w-[40ch]"
bind:value={selectedConvocatoria}
onchange={() => loadEscanos()}
>
{#each convocatorias as c}
<option value={c.idConvocatoria}>{c.Titulo}</option>
{/each}
</select>
</fieldset>
<!-- Fila 1: Escaños -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-md p-4">
<h3 class="text-lg font-bold mb-2">Escaños por Candidatura</h3>
<BarEscanos
{labels}
{colors}
{escanos}
{votos}
{porcentajes}
/>
</div>
<div class="card bg-base-100 shadow-md p-4">
<h3 class="text-lg font-bold mb-2">Distribución de Escaños</h3>
<SemiDonutEscanos
{labels}
{colors}
{escanos}
/>
</div>
</div>
<!-- Fila 2: Evolutivos -->
<div class="card bg-base-100 shadow-md p-4">
<h3 class="text-lg font-bold mb-4">Evolutivos Históricos</h3>
<!-- Pestañas -->
<div class="flex gap-2 mb-4">
<button
type="button"
class="px-4 py-2 rounded-lg border shadow-sm font-semibold
transition-all duration-200
{tab === 'votos'
? 'bg-blue-600 text-white border-blue-700 shadow-md'
: 'bg-base-100 text-base-content hover:bg-base-200'}"
onclick={() => tab = 'votos'}
>
Votos
</button>
<button
type="button"
class="px-4 py-2 rounded-lg border shadow-sm font-semibold
transition-all duration-200
{tab === 'porc'
? 'bg-green-600 text-white border-green-700 shadow-md'
: 'bg-base-100 text-base-content hover:bg-base-200'}"
onclick={() => tab = 'porc'}
>
Porcentajes
</button>
<button
type="button"
class="px-4 py-2 rounded-lg border shadow-sm font-semibold
transition-all duration-200
{tab === 'esc'
? 'bg-red-600 text-white border-red-700 shadow-md'
: 'bg-base-100 text-base-content hover:bg-base-200'}"
onclick={() => tab = 'esc'}
>
Escaños
</button>
</div>
<!-- Contenido -->
{#if tab === "votos"}
<EvolutivoVotos
matriz={matriz}
convocatorias={evoConvocatorias}
candidaturas={candidaturas}
colores={coloresCandidaturas}
/>
{:else if tab === "porc"}
<EvolutivoPorcentaje
matriz={matriz}
convocatorias={evoConvocatorias}
candidaturas={candidaturas}
colores={coloresCandidaturas}
/>
{:else}
<EvolutivoEscanos
matriz={matriz}
convocatorias={evoConvocatorias}
candidaturas={candidaturas}
colores={coloresCandidaturas}
/>
{/if}
</div>
</div>
<script>
import ApexCharts from "apexcharts";
const { labels, colors, escanos, votos, porcentajes } = $props();
let chart = $state(null);
let chartEl = $state(null);
$effect(() => {
if (!labels.length) return;
const options = {
chart: { type: "bar", height: 400, toolbar: { show: false } },
series: [{ name: "Escaños", data: escanos }],
colors,
plotOptions: {
bar: { distributed: true, borderRadius: 4, columnWidth: "55%" }
},
states: { // Para que no actue el click en los elementos del gráfico
active: {
filter: {
type: 'none'
}
},
hover: {
filter: {
type: 'none'
}
}
},
xaxis: {
categories: labels,
labels: {
style: {
colors,
fontSize: "14px",
fontWeight: 600
}
}
},
legend: { show: false },
dataLabels: { enabled: true, formatter: val => val },
tooltip: {
custom: ({ dataPointIndex }) => {
const codigo = labels[dataPointIndex];
const color = colors[dataPointIndex];
const esc = escanos[dataPointIndex];
const totalVotos = votos[dataPointIndex].toLocaleString("es-ES");
const porcentaje = porcentajes[dataPointIndex]
.toFixed(2)
.replace(".", ",") + "%";
return `
<div style="background:${color};color:white;padding:8px;border-radius:6px;">
<strong>${codigo}</strong><br/>
Escaños: ${esc}<br/>
Votos: ${totalVotos}<br/>
${porcentaje}
</div>
`;
}
}
};
if (!chart) {
chart = new ApexCharts(chartEl, options);
chart.render();
} else {
chart.updateOptions(options);
}
});
</script>
<div class="max-h-112.5" bind:this={chartEl}></div>
<script>
import ApexCharts from "apexcharts";
const { labels, colors, escanos } = $props();
let chart = $state(null);
let chartEl = $state(null);
let totalEscanos = $state(0);
$effect(() => {
if (!escanos.length) return;
// 🔥 Total de escaños de la convocatoria
totalEscanos = escanos.reduce((a, b) => a + b, 0);
const options = {
chart: {
type: "donut",
width: 450,
height: 400, // "auto",
offsetY: +150, // 🔥 mueve el donut hacia arriba o para abajo
},
/*
grid: {
padding: {
top: +160, // sube un poco más el gráfico
// bottom: +20 // reduce espacio bajo el gráfico
}
},
*/
series: escanos,
labels: labels, // ← siglas (PSOE, PP, VOX…)
colors: colors,
plotOptions: {
pie: {
startAngle: -90,
endAngle: 90,
expandOnClick: true, // mantiene la expansión en Selected
customScale: 1, // evita reescalado interno
// 🔥 Esta línea es la clave
offsetX: 0, // Sets the left offset of the whole pie area, default = 0
offsetY: 16, // Sets the top offset of the whole pie area, default = 16
donut: {
size: "50%",
labels: {
show: true,
total: {
show: true,
showAlways: true, // 🔥 solo aparece al hacer hover
// 🔥 Solo una línea, sin formatter
label: "Total escaños: " + totalEscanos,
// 🔥 Desactivamos formatter para evitar la segunda línea
formatter: () => ""
}
}
},
stroke: { // 🔥 borde blanco entre segmentos
width: 2,
colors: ['#ffffff'] // ✔ array → funciona
},
// Para eliminar sombreado oscuro en la selección de un segmento, usamos selected: { filter: { type: 'none' } }
states: {
active: {
filter: {
type: 'lighten' // 🔥 evita el sombreado oscuro
// type: 'lighten',
// value: 0.5
}
},
hover: {
filter: {
type: 'lighten',
// value: 0.05
}
},
},
}
},
// 🔥 Texto visible en cada porción = número de escaños
dataLabels: {
enabled: true,
formatter: (_, { seriesIndex }) => escanos[seriesIndex],
style: {
fontSize: "12px",
fontWeight: "bold",
colors: ["#fff"]
}
},
tooltip: {
custom: ({ seriesIndex }) => {
const sigla = labels[seriesIndex];
const esc = escanos[seriesIndex];
const color = colors[seriesIndex];
return `
<div style="background:${color};padding:8px;color:white;border-radius:6px;">
<strong>${sigla}</strong><br/>
Escaños: ${esc}
</div>
`;
}
},
legend: {
show: true,
position: "bottom",
horizontalAlign: "center",
offsetY: 10 , // si quieres, puedes subirla con un valor negativo
},
// 🟦 Responsive para pantallas pequeñas
responsive: [
{
breakpoint: 1024,
options: {
chart: {
width: 350,
height: 400,
},
plotOptions: {
pie: {
donut: {
size: "55%",
}
}
},
dataLabels: {
style: {
fontSize: "12px"
}
},
legend: {
show: true,
position: "bottom",
horizontalAlign: "center",
offsetY: 20, // si quieres, puedes subirla con un valor negativo
}
},
},
],
};
if (!chart) {
chart = new ApexCharts(chartEl, options);
chart.render();
} else {
chart.updateOptions(options);
}
});
</script>
<div class="w-full flex items-center justify-center">
<!-- Semidonut recortado y centrado -->
<!-- Esta es la línea origina, la dejo para que se entienda el cambio de tamaño y posición del gráfico
<div class="relative w-[450px] h-[300px] overflow-hidden flex items-center justify-center">
<div class="absolute inset-0 -translate-y-[30%]" bind:this={chartEl}></div>
</div>
-->
<div class="relative w-112.5 h-75 overflow-hidden flex items-center justify-center">
<div class="absolute inset-0 translate-y-[-30%]" bind:this={chartEl}></div>
</div>
</div>
<script>
import ApexCharts from "apexcharts";
const { matriz, convocatorias, candidaturas, colores } = $props();
let chart = $state(null);
let chartEl = $state(null);
function buildSeries() {
return candidaturas.map(cand => ({
name: cand,
data: convocatorias.map(conv => {
const cell = matriz[cand]?.[conv];
return cell ? cell.escanos : null; // 🔥 NULO si no hay datos
})
}));
}
$effect(() => {
if (!candidaturas.length || !convocatorias.length) return;
const options = {
chart: { type: "line", height: 350 },
series: buildSeries(),
colors: candidaturas.map(c => colores[c] ?? "#CCCCCC"),
xaxis: { categories: convocatorias },
stroke: { width: 3 },
markers: { size: 4 }
};
if (!chart) {
chart = new ApexCharts(chartEl, options);
chart.render();
} else {
chart.updateOptions(options);
}
});
</script>
<div bind:this={chartEl}></div>
Aplicación de Back-End
Como he indicado, tiene la estructura de los directorios, habituales, con su fichero de configuración de «include/Config.php». La diferencia de este ejemplo con otros es el fichero «v1/index.php», que tiene este código:
La diferencia principal es que he comentado todos los comando de «header» y los accesos CORS he ha resuelto con Middleware de CORS.
Espero que os guste y que os ayudea resolver vuestros requisitos. Para las dudas que tengáis podéis poneros en contacto conmigo a través de mi email.
Os dejo los fuentes para que podáis descargarlos e instalarlos en vuestros PC’s.