diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 36a8836..a263482 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,6 +14,8 @@
"@tabler/icons": "^3.31.0",
"@tabler/icons-react": "^3.31.0",
"axios": "^1.9.0",
+ "echarts": "^5.6.0",
+ "echarts-for-react": "^3.0.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-rnd": "^10.5.2",
@@ -4182,6 +4184,36 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/echarts": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
+ "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "2.3.0",
+ "zrender": "5.6.1"
+ }
+ },
+ "node_modules/echarts-for-react": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.2.tgz",
+ "integrity": "sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "size-sensor": "^1.0.1"
+ },
+ "peerDependencies": {
+ "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0",
+ "react": "^15.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/echarts/node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -4906,7 +4938,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -8596,6 +8627,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/size-sensor": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz",
+ "integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==",
+ "license": "ISC"
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -10644,6 +10681,21 @@
"zod": "^3.24.1"
}
},
+ "node_modules/zrender": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
+ "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tslib": "2.3.0"
+ }
+ },
+ "node_modules/zrender/node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
"node_modules/zustand": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 93319b7..7296dbd 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -26,6 +26,8 @@
"@tabler/icons": "^3.31.0",
"@tabler/icons-react": "^3.31.0",
"axios": "^1.9.0",
+ "echarts": "^5.6.0",
+ "echarts-for-react": "^3.0.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-rnd": "^10.5.2",
diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx
index 46408a3..272eaed 100644
--- a/frontend/src/Router.tsx
+++ b/frontend/src/Router.tsx
@@ -4,6 +4,8 @@ import { Consulta_API } from './pages/Consulta_api';
import { Error_404 } from './pages/404'; // Ajusta si está en otra carpeta
import { Grid_Dashboard } from './pages/Grid_dashboard'; // Ajusta si está en otra carpeta
import { Biblioteca } from './pages/Biblioteca';
+import { VisualizacionesRandom } from './pages/Visualizaciones_Random';
+import { Camara_noir } from './pages/Camaras_noir';
const router = createBrowserRouter([
{
@@ -22,6 +24,14 @@ const router = createBrowserRouter([
path: '/Biblioteca',
element: ,
},
+ {
+ path: '/analytics/Visualizaciones_Random',
+ element: ,
+ },
+ {
+ path: '/analytics/Camaras',
+ element: ,
+ },
{
diff --git a/frontend/src/components/Camara_noir/CanvasDisplay.tsx b/frontend/src/components/Camara_noir/CanvasDisplay.tsx
new file mode 100644
index 0000000..d505065
--- /dev/null
+++ b/frontend/src/components/Camara_noir/CanvasDisplay.tsx
@@ -0,0 +1,26 @@
+// Archivo: components/CanvasDisplay.tsx
+import { Box } from '@mantine/core';
+import { RefObject } from 'react';
+
+interface CanvasDisplayProps {
+ canvasRef: RefObject;
+}
+
+export function CanvasDisplay({ canvasRef }: CanvasDisplayProps) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/Camara_noir/CaptureGrid.tsx b/frontend/src/components/Camara_noir/CaptureGrid.tsx
new file mode 100644
index 0000000..2971fbb
--- /dev/null
+++ b/frontend/src/components/Camara_noir/CaptureGrid.tsx
@@ -0,0 +1,74 @@
+// Archivo: components/CaptureGrid.tsx
+import { useCallback } from 'react';
+import { SimpleGrid } from '@mantine/core';
+import { FrameCard } from './FrameCard';
+
+interface CaptureGridProps {
+ totalSlots: number;
+ capturas: string[][];
+ setCapturas: (value: string[][]) => void;
+ frameIndices: number[];
+ setFrameIndices: (value: number[]) => void;
+ fijados: boolean[];
+ setFijados: (value: boolean[]) => void;
+ frameTemporal: number | null;
+ onScroll: (e: React.WheelEvent, index: number) => void;
+ onSliderChange: (val: number) => void;
+ onSliderEnd: (index: number, val: number) => void;
+ numColumnas: number;
+}
+
+export function CaptureGrid({
+ totalSlots,
+ capturas,
+ setCapturas,
+ frameIndices,
+ setFrameIndices,
+ fijados,
+ setFijados,
+ frameTemporal,
+ onScroll,
+ onSliderChange,
+ onSliderEnd,
+ numColumnas,
+}: CaptureGridProps) {
+ const handleImageClick = useCallback((index: number, dataUrl: string) => {
+ const nuevasCapturas = [...capturas];
+ nuevasCapturas[index] = [dataUrl];
+ setCapturas(nuevasCapturas);
+
+ const nuevosFijados = [...fijados];
+ nuevosFijados[index] = true;
+ setFijados(nuevosFijados);
+
+ const nuevosIndices = [...frameIndices];
+ nuevosIndices[index] = 0;
+ setFrameIndices(nuevosIndices);
+ }, [capturas, fijados, frameIndices, setCapturas, setFijados, setFrameIndices]);
+
+ return (
+
+ {Array.from({ length: totalSlots }).map((_, i) => {
+ const frames = capturas[i] ?? [];
+ const currentIndex =
+ !fijados[i] && frameTemporal !== null && frames.length > 0
+ ? Math.max(0, Math.min(frames.length - 1, frameTemporal))
+ : frameIndices[i];
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/Camara_noir/ControlPanel.tsx b/frontend/src/components/Camara_noir/ControlPanel.tsx
new file mode 100644
index 0000000..ef6386c
--- /dev/null
+++ b/frontend/src/components/Camara_noir/ControlPanel.tsx
@@ -0,0 +1,25 @@
+// Archivo: components/ControlPanel.tsx
+import { Button, Group } from '@mantine/core';
+
+interface ControlPanelProps {
+ grabando: boolean;
+ onAlternar: () => void;
+ onLimpiar: () => void;
+ onDesfijar: () => void;
+}
+
+export function ControlPanel({ grabando, onAlternar, onLimpiar, onDesfijar }: ControlPanelProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Camara_noir/FrameCard.tsx b/frontend/src/components/Camara_noir/FrameCard.tsx
new file mode 100644
index 0000000..e8ce685
--- /dev/null
+++ b/frontend/src/components/Camara_noir/FrameCard.tsx
@@ -0,0 +1,125 @@
+// Archivo: components/FrameCard.tsx
+import { Box, HoverCard, Image, Slider, Stack, Text } from '@mantine/core';
+
+interface FrameCardProps {
+ index: number;
+ frames: string[];
+ currentIndex: number;
+ fijado: boolean;
+ onScroll: (e: React.WheelEvent, index: number) => void;
+ onSliderChange: (val: number) => void;
+ onSliderEnd: (index: number, val: number) => void;
+ onImageClick?: (index: number, dataUrl: string) => void;
+}
+
+export function FrameCard({
+ index,
+ frames,
+ currentIndex,
+ fijado,
+ onScroll,
+ onSliderChange,
+ onSliderEnd,
+ onImageClick,
+}: FrameCardProps) {
+ const borderColor = fijado ? '#4caf50' : '#ccc';
+
+ return (
+
+
+
+ {frames.length > 0 && currentIndex < frames.length && (
+ <>
+
+
+ {currentIndex + 1} / {frames.length}
+
+ >
+ )}
+
+
+
+ onScroll(e, index)}>
+ {frames.length > 0 && currentIndex < frames.length && (
+
+ {
+ const imgElement = e.currentTarget;
+ const img = document.createElement('img');
+ img.src = frames[currentIndex];
+ await img.decode();
+
+ const rect = imgElement.getBoundingClientRect();
+ const clickX = e.clientX - rect.left;
+ const clickY = e.clientY - rect.top;
+ const ratioX = clickX / rect.width;
+ const ratioY = clickY / rect.height;
+
+ const zoomFactor = 2;
+ const cropWidth = img.width / zoomFactor;
+ const cropHeight = img.height / zoomFactor;
+
+ const centerX = img.width * ratioX;
+ const centerY = img.height * ratioY;
+
+ const x = Math.max(0, Math.min(img.width - cropWidth, centerX - cropWidth / 2));
+ const y = Math.max(0, Math.min(img.height - cropHeight, centerY - cropHeight / 2));
+
+ const canvas = document.createElement('canvas');
+ canvas.width = cropWidth;
+ canvas.height = cropHeight;
+
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.drawImage(img, x, y, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
+ const dataUrl = canvas.toDataURL('image/jpeg');
+ onImageClick?.(index, dataUrl);
+ }
+ }}
+ />
+ onSliderChange(val)}
+ onChangeEnd={(val) => onSliderEnd(index, val)}
+ min={0}
+ max={frames.length - 1}
+ />
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/Camara_noir/GridConfigPanel.tsx b/frontend/src/components/Camara_noir/GridConfigPanel.tsx
new file mode 100644
index 0000000..82701ef
--- /dev/null
+++ b/frontend/src/components/Camara_noir/GridConfigPanel.tsx
@@ -0,0 +1,36 @@
+// Archivo: components/GridConfigPanel.tsx
+import { NumberInput, Stack, Text } from '@mantine/core';
+
+interface GridConfigPanelProps {
+ numFilas: number;
+ setNumFilas: (val: number) => void;
+ numColumnas: number;
+ setNumColumnas: (val: number) => void;
+}
+
+export function GridConfigPanel({ numFilas, setNumFilas, numColumnas, setNumColumnas }: GridConfigPanelProps) {
+ return (
+
+ Cartas
+ setNumFilas(Number(val))}
+ min={1}
+ max={5}
+ step={1}
+ size="xs"
+ style={{ width: 60 }}
+ />
+ Jugadores
+ setNumColumnas(Number(val))}
+ min={1}
+ max={11}
+ step={1}
+ size="xs"
+ style={{ width: 60 }}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/Camara_noir/index.ts b/frontend/src/components/Camara_noir/index.ts
new file mode 100644
index 0000000..7a6c889
--- /dev/null
+++ b/frontend/src/components/Camara_noir/index.ts
@@ -0,0 +1,6 @@
+export * from './CanvasDisplay';
+export * from './ControlPanel';
+export * from './GridConfigPanel';
+export * from './CaptureGrid';
+export * from './FrameCard';
+export * from './useCamaraNoir';
\ No newline at end of file
diff --git a/frontend/src/components/Camara_noir/useCamaraNoir.tsx b/frontend/src/components/Camara_noir/useCamaraNoir.tsx
new file mode 100644
index 0000000..f16af73
--- /dev/null
+++ b/frontend/src/components/Camara_noir/useCamaraNoir.tsx
@@ -0,0 +1,212 @@
+import { useEffect, useRef, useState } from 'react';
+
+export function useCamaraNoir() {
+ const canvasRef = useRef(null);
+ const bufferPrevioRef = useRef([]);
+ const bufferGrabacionRef = useRef([]);
+ const bufferAcumuladoRef = useRef([]);
+ const pregrabacionActivaRef = useRef(true);
+ const primeraGrabacionRealizadaRef = useRef(false);
+
+ const [bufferGlobal, setBufferGlobal] = useState([]);
+ const [intervaloId, setIntervaloId] = useState | null>(null);
+ const [grabando, setGrabando] = useState(false);
+
+ const [capturas, setCapturas] = useState([]);
+ const [frameIndices, setFrameIndices] = useState([]);
+ const [fijados, setFijados] = useState([]);
+ const [frameTemporal, setFrameTemporal] = useState(null);
+
+ const [numFilas, setNumFilas] = useState(2);
+ const [numColumnas, setNumColumnas] = useState(10);
+
+ const DELAY_ENTRE_FRAMES_MS = 10;
+ const SEGUNDOS_PRE_GRABACION = 5;
+ const FPS = 1000 / DELAY_ENTRE_FRAMES_MS;
+ const FRAMES_PRE_GRABACION = Math.floor(FPS * SEGUNDOS_PRE_GRABACION);
+ const totalSlots = numFilas * numColumnas;
+
+ useEffect(() => {
+ const id = setInterval(() => {
+ if (!pregrabacionActivaRef.current) return;
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const frame = canvas.toDataURL('image/jpeg');
+ bufferPrevioRef.current.push(frame);
+ if (bufferPrevioRef.current.length > FRAMES_PRE_GRABACION) {
+ bufferPrevioRef.current.shift();
+ }
+ }, DELAY_ENTRE_FRAMES_MS);
+
+ return () => clearInterval(id);
+ }, []);
+
+ const desfijarTodos = () => {
+ setFijados(Array(totalSlots).fill(false));
+ };
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const socket = new WebSocket('ws://10.8.0.9:8000/ws');
+ socket.binaryType = 'blob';
+
+ socket.onmessage = async (event) => {
+ if (event.data instanceof Blob) {
+ const imgBitmap = await createImageBitmap(event.data);
+ ctx.drawImage(imgBitmap, 0, 0, canvas.width, canvas.height);
+ }
+ };
+
+ socket.onerror = (e) => console.error('WebSocket error:', e);
+ socket.onclose = () => console.warn('WebSocket cerrado');
+
+ return () => socket.close();
+ }, []);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.code === 'Space') {
+ event.preventDefault();
+ alternarGrabacion();
+ } else if (event.key.toLowerCase() === 'f') {
+ event.preventDefault();
+ limpiarCapturas();
+ } else if (event.key.toLowerCase() === 'd') {
+ event.preventDefault();
+ desfijarTodos();
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [grabando, bufferGlobal, totalSlots]);
+
+ useEffect(() => {
+ const total = numFilas * numColumnas;
+ const nuevoCapturas = Array(total)
+ .fill(null)
+ .map((_, i) => capturas[i] ?? bufferGlobal);
+ const nuevoIndices = Array(total)
+ .fill(null)
+ .map((_, i) => frameIndices[i] ?? (frameTemporal ?? Math.floor(bufferGlobal.length / 2)));
+ const nuevosFijados = Array(total)
+ .fill(null)
+ .map((_, i) => fijados[i] ?? false);
+
+ setCapturas(nuevoCapturas);
+ setFrameIndices(nuevoIndices);
+ setFijados(nuevosFijados);
+ }, [numFilas, numColumnas]);
+
+ const iniciarGrabacion = () => {
+ if (grabando) return;
+ setGrabando(true);
+ bufferGrabacionRef.current = [];
+
+ if (!primeraGrabacionRealizadaRef.current) {
+ pregrabacionActivaRef.current = false;
+ }
+
+ const id = setInterval(() => {
+ const frame = capturarFrame();
+ if (frame) {
+ bufferGrabacionRef.current.push(frame);
+ }
+ }, DELAY_ENTRE_FRAMES_MS);
+
+ setIntervaloId(id);
+ };
+
+ const capturarFrame = (): string | null => {
+ const canvas = canvasRef.current;
+ if (!canvas) return null;
+ return canvas.toDataURL('image/jpeg');
+ };
+
+ const detenerGrabacion = () => {
+ if (!grabando) return;
+ setGrabando(false);
+ if (intervaloId) clearInterval(intervaloId);
+
+ const nuevaSesion = primeraGrabacionRealizadaRef.current
+ ? [...bufferGrabacionRef.current] // solo frames nuevos
+ : [...bufferPrevioRef.current, ...bufferGrabacionRef.current];
+
+ bufferAcumuladoRef.current.push(...nuevaSesion);
+ primeraGrabacionRealizadaRef.current = true;
+
+ setBufferGlobal([...bufferAcumuladoRef.current]);
+ setCapturas(Array(totalSlots).fill([...bufferAcumuladoRef.current]));
+ const frameInicial = Math.floor(bufferAcumuladoRef.current.length / 2);
+ setFrameIndices(Array(totalSlots).fill(frameInicial));
+ setFijados(Array(totalSlots).fill(false));
+ setFrameTemporal(frameInicial);
+ };
+
+ const alternarGrabacion = () => {
+ grabando ? detenerGrabacion() : iniciarGrabacion();
+ };
+
+ const limpiarCapturas = () => {
+ setCapturas([]);
+ setFrameIndices([]);
+ setFijados([]);
+ setBufferGlobal([]);
+ bufferGrabacionRef.current = [];
+ bufferPrevioRef.current = [];
+ bufferAcumuladoRef.current = [];
+ primeraGrabacionRealizadaRef.current = false;
+ pregrabacionActivaRef.current = true;
+ setFrameTemporal(null);
+ if (intervaloId) clearInterval(intervaloId);
+ setGrabando(false);
+ };
+
+ const moverFrame = (capturaIndex: number, nuevoIndice: number) => {
+ setFrameIndices((prev) => {
+ const nuevos = [...prev];
+ const max = capturas[capturaIndex]?.length - 1 ?? 0;
+ nuevos[capturaIndex] = Math.max(0, Math.min(max, nuevoIndice));
+ return nuevos;
+ });
+ };
+
+ const moverFrameYFijar = (capturaIndex: number, nuevoIndice: number) => {
+ moverFrame(capturaIndex, nuevoIndice);
+ setFijados((prev) => {
+ const actualizados = [...prev];
+ actualizados[capturaIndex] = true;
+ return actualizados;
+ });
+ setFrameTemporal(nuevoIndice);
+ };
+
+ const manejarScrollEnSlider = (e: React.WheelEvent, index: number) => {
+ e.preventDefault();
+ if (!fijados[index]) return;
+ moverFrame(index, frameIndices[index] + (e.deltaY > 0 ? 1 : -1));
+ };
+
+ return {
+ canvasRef,
+ bufferGlobal,
+ grabando,
+ capturas,
+ frameIndices,
+ fijados,
+ frameTemporal,
+ numFilas,
+ setNumFilas,
+ numColumnas,
+ setNumColumnas,
+ alternarGrabacion,
+ limpiarCapturas,
+ desfijarTodos,
+ manejarScrollEnSlider,
+ moverFrameYFijar,
+ setFrameTemporal,
+ };
+}
\ No newline at end of file
diff --git a/frontend/src/data/submenuLinks_1.ts b/frontend/src/data/submenuLinks_1.ts
index 9dd4182..6e31d15 100644
--- a/frontend/src/data/submenuLinks_1.ts
+++ b/frontend/src/data/submenuLinks_1.ts
@@ -14,8 +14,8 @@ export const submenuLinks = {
{ label: 'Usuarios', to: '/dashboard/usuarios' },
],
Analytics: [
- { label: 'Conversiones', to: '/analytics/conversiones' },
- { label: 'Tráfico', to: '/analytics/trafico' },
+ { label: 'Visualizaciones_Random', to: '/analytics/Visualizaciones_Random' },
+ { label: 'Camaras', to: '/analytics/Camaras' },
{ label: 'Tendencias', to: '/analytics/tendencias' },
],
Releases: [
diff --git a/frontend/src/pages/Camaras_noir.tsx b/frontend/src/pages/Camaras_noir.tsx
new file mode 100644
index 0000000..1ed3bb2
--- /dev/null
+++ b/frontend/src/pages/Camaras_noir.tsx
@@ -0,0 +1,57 @@
+import { AppShellWithMenu } from '../components/Appshell/Appshell';
+import { Stack, Box } from '@mantine/core';
+
+import {
+ CanvasDisplay,
+ ControlPanel,
+ CaptureGrid,
+ GridConfigPanel,
+ useCamaraNoir
+} from '../components/Camara_noir';
+
+export function Camara_noir() {
+ const camara = useCamaraNoir();
+ const totalSlots = camara.numFilas * camara.numColumnas;
+
+ return (
+
+
+
+ {/* Contenedor para botones encima del video */}
+
+
+
+
+
+
+
+ {/* Grilla de capturas + configurador lateral */}
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Visualizaciones_Random.tsx b/frontend/src/pages/Visualizaciones_Random.tsx
new file mode 100644
index 0000000..7f6ab8d
--- /dev/null
+++ b/frontend/src/pages/Visualizaciones_Random.tsx
@@ -0,0 +1,53 @@
+import { AppShellWithMenu } from '../components/Appshell/Appshell';
+import { Card, Grid, Title, Loader } from '@mantine/core';
+import { useEffect, useState } from 'react';
+import ReactECharts from 'echarts-for-react';
+
+type ChartOption = any;
+
+function useChartOption(endpoint: string) {
+ const [option, setOption] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch(`/api/${endpoint}`)
+ .then((res) => res.json())
+ .then((json) => setOption(json))
+ .catch(console.error)
+ .finally(() => setLoading(false));
+ }, [endpoint]);
+
+ return { option, loading };
+}
+
+export function VisualizacionesRandom() {
+ const charts = [
+ { title: 'Gráfico de barras', endpoint: 'bar' },
+ { title: 'Gráfico de líneas', endpoint: 'line' },
+ { title: 'Gráfico de pastel', endpoint: 'pie' },
+ { title: 'Scatter plot', endpoint: 'scatter' },
+ ];
+
+ return (
+
+
+ {charts.map(({ title, endpoint }, idx) => {
+ const { option, loading } = useChartOption(endpoint);
+
+ return (
+
+
+ {title}
+ {loading || !option ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 88f21c0..1416e05 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1735,6 +1735,22 @@ eastasianwidth@^0.2.0:
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
+echarts-for-react@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.2.tgz"
+ integrity sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==
+ dependencies:
+ fast-deep-equal "^3.1.3"
+ size-sensor "^1.0.1"
+
+"echarts@^3.0.0 || ^4.0.0 || ^5.0.0", echarts@^5.6.0:
+ version "5.6.0"
+ resolved "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz"
+ integrity sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==
+ dependencies:
+ tslib "2.3.0"
+ zrender "5.6.1"
+
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
@@ -3810,7 +3826,7 @@ react-use-measure@^2.1.7:
resolved "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz"
integrity sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==
-"react@^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react@^18.0.0 || ^19.0.0", "react@^18.x || ^19.x", react@^19.0.0, react@^19.1.0, "react@>= 16", "react@>= 16.3.0", react@>=16.13, react@>=16.3.0, react@>=16.8.0, react@>=17.0, react@>=18, react@>=18.0.0:
+"react@^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^15.0.0 || >=16.0.0", "react@^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react@^18.0.0 || ^19.0.0", "react@^18.x || ^19.x", react@^19.0.0, react@^19.1.0, "react@>= 16", "react@>= 16.3.0", react@>=16.13, react@>=16.3.0, react@>=16.8.0, react@>=17.0, react@>=18, react@>=18.0.0:
version "19.1.0"
resolved "https://registry.npmjs.org/react/-/react-19.1.0.tgz"
integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==
@@ -4145,6 +4161,11 @@ signal-exit@^4.0.1:
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
+size-sensor@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz"
+ integrity sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==
+
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"
@@ -4620,6 +4641,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0:
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+tslib@2.3.0:
+ version "2.3.0"
+ resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz"
+ integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
+
tslib@2.6.2:
version "2.6.2"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
@@ -5064,6 +5090,13 @@ zod@^3.23.8, zod@^3.24.1, zod@^3.24.2:
resolved "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz"
integrity sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==
+zrender@5.6.1:
+ version "5.6.1"
+ resolved "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz"
+ integrity sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==
+ dependencies:
+ tslib "2.3.0"
+
zustand@^5.0.3:
version "5.0.4"
resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.4.tgz"
diff --git a/pruebas_conceptos/emision_datos_fastapi/data_fastapi.py b/pruebas_conceptos/emision_datos_fastapi/data_fastapi.py
new file mode 100644
index 0000000..01d1515
--- /dev/null
+++ b/pruebas_conceptos/emision_datos_fastapi/data_fastapi.py
@@ -0,0 +1,69 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+app = FastAPI()
+
+# Permitir acceso desde el frontend
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"], # cámbialo por ["http://localhost:5173"] si es necesario
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+@app.get("/api/bar")
+def get_bar_chart():
+ return {
+ "xAxis": {"type": "category", "data": ["Ene", "Feb", "Mar", "Abr"]},
+ "yAxis": {"type": "value"},
+ "series": [{"data": [5, 20, 36, 10], "type": "bar"}]
+ }
+
+@app.get("/api/line")
+def get_line_chart():
+ return {
+ "xAxis": {"type": "category", "data": ["Semana 1", "Semana 2", "Semana 3", "Semana 4"]},
+ "yAxis": {"type": "value"},
+ "series": [{"data": [15, 25, 18, 30], "type": "line"}]
+ }
+
+@app.get("/api/pie")
+def get_pie_chart():
+ return {
+ "tooltip": {"trigger": "item"},
+ "legend": {"top": "5%", "left": "center"},
+ "series": [{
+ "name": "Accesos",
+ "type": "pie",
+ "radius": ["40%", "70%"],
+ "avoidLabelOverlap": False,
+ "itemStyle": {"borderRadius": 10, "borderColor": "#fff", "borderWidth": 2},
+ "label": {"show": False, "position": "center"},
+ "emphasis": {
+ "label": {"show": True, "fontSize": 16, "fontWeight": "bold"}
+ },
+ "labelLine": {"show": False},
+ "data": [
+ {"value": 1048, "name": "Search"},
+ {"value": 735, "name": "Direct"},
+ {"value": 580, "name": "Email"},
+ {"value": 484, "name": "Union Ads"},
+ {"value": 300, "name": "Video Ads"}
+ ]
+ }]
+ }
+
+@app.get("/api/scatter")
+def get_scatter_chart():
+ return {
+ "xAxis": {},
+ "yAxis": {},
+ "series": [{
+ "symbolSize": 20,
+ "data": [
+ [10, 8], [20, 20], [30, 10], [40, 30], [50, 15]
+ ],
+ "type": "scatter"
+ }]
+ }
\ No newline at end of file