Public
Edited
Jun 3
2 forks
Insert cell
Insert cell
// === CELDA 1: Cargar datos ===
graph = FileAttachment("MC1_graph.json").json()
Insert cell
// === CELDA 2: Extraer nodos y enlaces ===
nodes = graph.nodes;
Insert cell
Insert cell
// Filtrar por Sailor Shift
sailorNodes = nodes.filter(d => d.name && d.name.includes("Sailor Shift"))
Insert cell
// === CELDA 5: Estadísticas del Dataset Musical ===
viewof dataStats = {
const nodeTypes = d3.rollup(nodes, v => v.length, d => d["Node Type"]);
const linkTypes = d3.rollup(links, v => v.length, d => d["Edge Type"]);
const genres = d3.rollup(nodes.filter(d => d.genre), v => v.length, d => d.genre);
return html`
<div style="background: #f8f9fa; padding: 20px; border-radius: 10px; margin: 10px 0; font-family: -apple-system, BlinkMacSystemFont, sans-serif;">
<h3 style="margin-top: 0; color: #2c3e50;">📊 Estadísticas del Dataset Musical</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; margin: 15px 0;">
<div style="background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h4 style="margin: 0 0 10px 0; color: #34495e;">🎵 Nodos Totales</h4>
<p style="font-size: 24px; font-weight: bold; margin: 0; color: #e74c3c;">${nodes.length.toLocaleString()}</p>
</div>
<div style="background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h4 style="margin: 0 0 10px 0; color: #34495e;">🔗 Enlaces Totales</h4>
<p style="font-size: 24px; font-weight: bold; margin: 0; color: #e74c3c;">${links.length.toLocaleString()}</p>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-top: 20px;">
<div style="background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h4 style="margin: 0 0 10px 0; color: #34495e;">📋 Tipos de Nodos</h4>
${Array.from(nodeTypes, ([type, count]) => `
<div style="display: flex; justify-content: space-between; margin: 5px 0;">
<span>${type}:</span>
<strong>${count.toLocaleString()}</strong>
</div>
`).join("")}
</div>
<div style="background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h4 style="margin: 0 0 10px 0; color: #34495e;">🎼 Top Géneros</h4>
${Array.from(genres)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([genre, count]) => `
<div style="display: flex; justify-content: space-between; margin: 5px 0;">
<span>${genre}:</span>
<strong style="color: #7f8c8d">${count}</strong>
</div>
`).join("")}
</div>
</div>
</div>
`;
}

Insert cell
// === CELDA 6: Gráfico de barras para géneros ===
{
const genreData = Array.from(d3.rollup(nodes.filter(d => d.genre), v => v.length, d => d.genre))
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const width = 800;
const height = 400;
const margin = {top: 20, right: 30, bottom: 80, left: 60};
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const x = d3.scaleBand()
.domain(genreData.map(d => d[0]))
.range([margin.left, width - margin.right])
.padding(0.1);
const y = d3.scaleLinear()
.domain([0, d3.max(genreData, d => d[1])])
.nice()
.range([height - margin.bottom, margin.top]);
const color = d3.scaleOrdinal(d3.schemeCategory10);
svg.append("g")
.selectAll("rect")
.data(genreData)
.enter().append("rect")
.attr("x", d => x(d[0]))
.attr("y", d => y(d[1]))
.attr("height", d => y(0) - y(d[1]))
.attr("width", x.bandwidth())
.attr("fill", (d, i) => color(i));
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x))
.selectAll("text")
.attr("transform", "rotate(-45)")
.style("text-anchor", "end");
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y));
svg.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 0 - margin.left)
.attr("x", 0 - (height / 2))
.attr("dy", "1em")
.style("text-anchor", "middle")
.text("Cantidad");
svg.append("text")
.attr("transform", `translate(${width / 2}, ${height - 10})`)
.style("text-anchor", "middle")
.text("Géneros Musicales");
return svg.node();
}
Insert cell
// === CELDA 6: Red de conexiones de Sailor Shift ===
sailorNetwork = {
// Encontrar IDs de nodos relacionados con Sailor
const sailorIds = nodes
.filter(d => d.name && d.name.toLowerCase().includes("sailor"))
.map(d => d.id);
// Encontrar todas las conexiones
const sailorLinks = links.filter(d =>
sailorIds.includes(d.source) || sailorIds.includes(d.target)
);
// Nodos conectados
const connectedNodeIds = new Set([
...sailorLinks.map(d => d.source),
...sailorLinks.map(d => d.target)
]);
const networkNodes = nodes.filter(d => connectedNodeIds.has(d.id));
return {
nodes: networkNodes,
links: sailorLinks,
stats: {
nodes: networkNodes.length,
links: sailorLinks.length,
sailorNodes: sailorIds.length
}
};
}
Insert cell
// === CELDA 7: Gráfico circular para tipos de nodos ===
{
const nodeTypeData = Array.from(d3.rollup(nodes, v => v.length, d => d["Node Type"]));
const width = 500;
const height = 500;
const radius = Math.min(width, height) / 2 - 40;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const g = svg.append("g")
.attr("transform", `translate(${width / 2}, ${height / 2})`);
const color = d3.scaleOrdinal(d3.schemeSet3);
const pie = d3.pie()
.value(d => d[1])
.sort(null);
const arc = d3.arc()
.innerRadius(0)
.outerRadius(radius);
const labelArc = d3.arc()
.outerRadius(radius - 40)
.innerRadius(radius - 40);
const arcs = g.selectAll(".arc")
.data(pie(nodeTypeData))
.enter().append("g")
.attr("class", "arc");
arcs.append("path")
.attr("d", arc)
.attr("fill", (d, i) => color(i));
arcs.append("text")
.attr("transform", d => `translate(${labelArc.centroid(d)})`)
.attr("dy", "0.35em")
.style("text-anchor", "middle")
.style("font-size", "12px")
.text(d => d.data[0]);
// Leyenda
const legend = svg.selectAll(".legend")
.data(nodeTypeData)
.enter().append("g")
.attr("class", "legend")
.attr("transform", (d, i) => `translate(20, ${20 + i * 20})`);
legend.append("rect")
.attr("x", 0)
.attr("width", 18)
.attr("height", 18)
.style("fill", (d, i) => color(i));
legend.append("text")
.attr("x", 25)
.attr("y", 9)
.attr("dy", "0.35em")
.style("text-anchor", "start")
.text(d => `${d[0]}: ${d[1].toLocaleString()}`);
return svg.node();
}
Insert cell
// === CELDA 8: Buscar nodos de Oceanus Folk ===
oceanusNodes = nodes.filter(d => d.genre === "Oceanus Folk")
Insert cell
// === CELDA 9: Análisis de Sailor Shift ===
sailorShiftAnalysis = {
const sailorShift = nodes.find(d => d.name === "Sailor Shift");
if (!sailorShift) {
return "No se encontró el nodo 'Sailor Shift'";
}
// Enlaces donde Sailor Shift es el origen
const outgoingLinks = links.filter(d => d.source === sailorShift.id || d.source === sailorNodes[0]?.id);
// Enlaces donde Sailor Shift es el destino
const incomingLinks = links.filter(d => d.target === sailorShift.id || d.target === sailorNodes[0]?.id);
return {
node: sailorShift,
outgoingLinks: outgoingLinks.length,
incomingLinks: incomingLinks.length,
totalConnections: outgoingLinks.length + incomingLinks.length
};
}
Insert cell
// === CELDA 10: Red de influencias de Sailor Shift ===
{
const sailorId = sailorNodes[0]?.id;
if (!sailorId) return html`<p>No se encontró Sailor Shift</p>`;
// Encontrar conexiones directas
const directConnections = links.filter(d =>
d.source === sailorId || d.target === sailorId
);
const connectedNodeIds = new Set();
directConnections.forEach(link => {
connectedNodeIds.add(link.source);
connectedNodeIds.add(link.target);
});
const networkNodes = nodes.filter(d => connectedNodeIds.has(d.id));
const networkLinks = directConnections;
const width = 800;
const height = 600;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const simulation = d3.forceSimulation(networkNodes)
.force("link", d3.forceLink(networkLinks).id(d => d.id).distance(100))
.force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(width / 2, height / 2));
const link = svg.append("g")
.selectAll("line")
.data(networkLinks)
.enter().append("line")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.attr("stroke-width", 2);
const node = svg.append("g")
.selectAll("circle")
.data(networkNodes)
.enter().append("circle")
.attr("r", d => d.name === "Sailor Shift" ? 15 : 8)
.attr("fill", d => d.name === "Sailor Shift" ? "#ff4444" : "#4444ff")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
const label = svg.append("g")
.selectAll("text")
.data(networkNodes)
.enter().append("text")
.text(d => d.name || d["Node Type"])
.style("font-size", "10px")
.attr("dx", 12)
.attr("dy", 4);
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
label
.attr("x", d => d.x)
.attr("y", d => d.y);
});
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return svg.node();
}
Insert cell
// === CELDA 7: Timeline Oceanus Folk ===
viewof timelineChart = {
const oceanusFolkNodes = nodes.filter(d =>
d.genre && d.genre.toLowerCase().includes("oceanus folk") && d.release_date
);
const yearData = d3.rollup(
oceanusFolkNodes,
v => ({
total: v.length,
notable: v.filter(d => d.notable === true).length,
songs: v.filter(d => d["Node Type"] === "Song").length,
albums: v.filter(d => d["Node Type"] === "Album").length
}),
d => +d.release_date
);
const data = Array.from(yearData, ([year, stats]) => ({
year,
...stats
})).sort((a, b) => a.year - b.year);
const width = 800;
const height = 400;
const margin = {top: 20, right: 80, bottom: 40, left: 60};
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const x = d3.scaleLinear()
.domain(d3.extent(data, d => d.year))
.range([margin.left, width - margin.right]);
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.total)])
.range([height - margin.bottom, margin.top]);
const line = d3.line()
.x(d => x(d.year))
.y(d => y(d.total))
.curve(d3.curveMonotoneX);
const notableLine = d3.line()
.x(d => x(d.year))
.y(d => y(d.notable))
.curve(d3.curveMonotoneX);
// Fondo
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", "#f8f9fa");
// Línea total
svg.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "#3498db")
.attr("stroke-width", 3)
.attr("d", line);
// Línea notable
svg.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "#e74c3c")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5")
.attr("d", notableLine);
// Puntos
svg.selectAll(".dot-total")
.data(data)
.enter().append("circle")
.attr("class", "dot-total")
.attr("cx", d => x(d.year))
.attr("cy", d => y(d.total))
.attr("r", 4)
.attr("fill", "#3498db");
svg.selectAll(".dot-notable")
.data(data)
.enter().append("circle")
.attr("class", "dot-notable")
.attr("cx", d => x(d.year))
.attr("cy", d => y(d.notable))
.attr("r", 3)
.attr("fill", "#e74c3c");
// Ejes
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickFormat(d3.format("d")));
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y));
// Título
svg.append("text")
.attr("x", width / 2)
.attr("y", 20)
.attr("text-anchor", "middle")
.style("font-size", "16px")
.style("font-weight", "bold")
.text("🌊 Evolución del Oceanus Folk por Año");
// Leyenda
const legend = svg.append("g")
.attr("transform", `translate(${width - 70}, 50)`);
legend.append("line")
.attr("x1", 0).attr("x2", 20)
.attr("y1", 0).attr("y2", 0)
.attr("stroke", "#3498db").attr("stroke-width", 3);
legend.append("text")
.attr("x", 25).attr("y", 5)
.style("font-size", "12px")
.text("Total");
legend.append("line")
.attr("x1", 0).attr("x2", 20)
.attr("y1", 20).attr("y2", 20)
.attr("stroke", "#e74c3c").attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5");
legend.append("text")
.attr("x", 25).attr("y", 25)
.style("font-size", "12px")
.text("Notable");
return svg.node();
}
Insert cell
// === CELDA 9: Heatmap Géneros por Año ===
viewof genreHeatmap = {
const songsWithGenre = nodes.filter(d =>
d["Node Type"] === "Song" && d.genre && d.release_date
);
const genreYearData = d3.rollup(
songsWithGenre,
v => v.length,
d => d.genre,
d => +d.release_date
);
const topGenres = Array.from(
d3.rollup(songsWithGenre, v => v.length, d => d.genre)
).sort((a, b) => b[1] - a[1]).slice(0, 10).map(d => d[0]);
const years = Array.from(new Set(songsWithGenre.map(d => +d.release_date))).sort();
const heatmapData = [];
topGenres.forEach(genre => {
years.forEach(year => {
const count = genreYearData.get(genre)?.get(year) || 0;
heatmapData.push({genre, year, count});
});
});
const width = 800;
const height = 400;
const margin = {top: 60, right: 40, bottom: 60, left: 120};
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const x = d3.scaleBand()
.domain(years)
.range([margin.left, width - margin.right])
.padding(0.1);
const y = d3.scaleBand()
.domain(topGenres)
.range([margin.top, height - margin.bottom])
.padding(0.1);
const colorScale = d3.scaleSequential(d3.interpolateBlues)
.domain([0, d3.max(heatmapData, d => d.count)]);
// Rectángulos del heatmap
svg.selectAll("rect")
.data(heatmapData)
.enter().append("rect")
.attr("x", d => x(d.year))
.attr("y", d => y(d.genre))
.attr("width", x.bandwidth())
.attr("height", y.bandwidth())
.attr("fill", d => d.count > 0 ? colorScale(d.count) : "#f8f9fa")
.attr("stroke", "#fff")
.attr("stroke-width", 1);
// Texto en cada celda
svg.selectAll("text.cell")
.data(heatmapData.filter(d => d.count > 0))
.enter().append("text")
.attr("class", "cell")
.attr("x", d => x(d.year) + x.bandwidth() / 2)
.attr("y", d => y(d.genre) + y.bandwidth() / 2)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.style("font-size", "10px")
.style("fill", d => d.count > 3 ? "white" : "black")
.text(d => d.count);
// Ejes
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x))
.selectAll("text")
.style("font-size", "10px")
.attr("transform", "rotate(-45)")
.style("text-anchor", "end");
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))
.selectAll("text")
.style("font-size", "10px");
// Título
svg.append("text")
.attr("x", width / 2)
.attr("y", 25)
.attr("text-anchor", "middle")
.style("font-size", "16px")
.style("font-weight", "bold")
.text("🎼 Distribución de Géneros Musicales por Año");
// Destacar Oceanus Folk
svg.selectAll("rect")
.filter(d => d.genre === "Oceanus Folk")
.attr("stroke", "#e74c3c")
.attr("stroke-width", 2);
return svg.node();
}
Insert cell
// === CELDA 10: Dashboard de Artistas Emergentes ===
viewof emergingArtists = {
// Calcular métricas para identificar estrellas emergentes
const artists = nodes.filter(d => d["Node Type"] === "Person");
const artistMetrics = artists.map(artist => {
const connections = links.filter(l => l.source === artist.id || l.target === artist.id);
const collaborations = connections.filter(l => l["Edge Type"] === "PerformerOf");
const productions = connections.filter(l => l["Edge Type"] === "ProducerOf");
// Buscar sus canciones/álbumes
const works = nodes.filter(w =>
connections.some(l =>
(l.source === artist.id && l.target === w.id) ||
(l.target === artist.id && l.source === w.id)
) && (w["Node Type"] === "Song" || w["Node Type"] === "Album")
);
const notableWorks = works.filter(w => w.notable === true);
const recentWorks = works.filter(w => w.release_date && +w.release_date >= 2035);
return {
id: artist.id,
name: artist.name || artist.stage_name || 'Desconocido',
totalConnections: connections.length,
collaborations: collaborations.length,
productions: productions.length,
totalWorks: works.length,
notableWorks: notableWorks.length,
recentWorks: recentWorks.length,
score: (notableWorks.length * 3) + (recentWorks.length * 2) + (collaborations.length * 1.5)
};
}).filter(a => a.score > 0).sort((a, b) => b.score - a.score).slice(0, 15);
return html`
<div style="background: #f8f9fa; padding: 20px; border-radius: 10px; margin: 10px 0;">
<h3 style="margin-top: 0; color: #2c3e50;">⭐ Top Artistas Emergentes (Predicción)</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px;">
${artistMetrics.slice(0, 6).map((artist, i) => `
<div style="background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border-left: 4px solid ${i < 3 ? '#e74c3c' : '#3498db'};">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h4 style="margin: 0; color: #2c3e50;">${i + 1}. ${artist.name}</h4>
<span style="background: ${i < 3 ? '#e74c3c' : '#3498db'}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: bold;">
Score: ${artist.score.toFixed(1)}
</span>
</div>
<div style="margin-top: 10px; font-size: 13px; color: #7f8c8d;">
<div>🎵 Obras totales: <strong>${artist.totalWorks}</strong></div>
<div>⭐ Obras notables: <strong>${artist.notableWorks}</strong></div>
<div>🆕 Obras recientes: <strong>${artist.recentWorks}</strong></div>
<div>🤝 Colaboraciones: <strong>${artist.collaborations}</strong></div>
</div>
</div>
`).join('')}
</div>
<div style="margin-top: 20px; padding: 15px; background: #d5f4e6; border-radius: 8px;">
<h4 style="margin: 0 0 10px 0; color: #27ae60;">🔮 Predicciones para los próximos 5 años:</h4>
<p style="margin: 5px 0;"><strong>1. ${artistMetrics[0]?.name || 'TBD'}</strong> - Mayor potencial por obras notables y colaboraciones activas</p>
<p style="margin: 5px 0;"><strong>2. ${artistMetrics[1]?.name || 'TBD'}</strong> - Crecimiento constante en producción reciente</p>
<p style="margin: 5px 0;"><strong>3. ${artistMetrics[2]?.name || 'TBD'}</strong> - Red sólida de conexiones en la industria</p>
</div>
</div>
`;
}
Insert cell
// === CELDA 13: Radar Chart de Géneros ===
viewof genreRadar = {
const genreStats = d3.rollup(
nodes.filter(d => d.genre && d["Node Type"] === "Song"),
v => ({
total: v.length,
notable: v.filter(d => d.notable).length,
recent: v.filter(d => d.release_date && +d.release_date >= 2035).length,
collaborations: v.filter(d => {
const collabs = links.filter(l =>
(l.source === d.id || l.target === d.id) &&
l["Edge Type"] === "PerformerOf"
);
return collabs.length > 1;
}).length
}),
d => d.genre
);
const topGenres = Array.from(genreStats)
.sort((a, b) => b[1].total - a[1].total)
.slice(0, 6);
const radarData = topGenres.map(([genre, stats]) => ({
genre,
total: Math.min(stats.total / 10, 10), // Normalizar a 0-10
notable: Math.min(stats.notable * 2, 10),
recent: Math.min(stats.recent * 1.5, 10),
collaborations: Math.min(stats.collaborations * 3, 10)
}));
const width = 500;
const height = 500;
const margin = 50;
const radius = Math.min(width, height) / 2 - margin;
const svg = d3.create("svg")
.attr("width", width + 300)
.attr("height", height);
const g = svg.append("g")
.attr("transform", `translate(${width/2}, ${height/2})`);
const axes = ["total", "notable", "recent", "collaborations"];
const angleSlice = Math.PI * 2 / axes.length;
// Crear escalas
const rScale = d3.scaleLinear()
.domain([0, 10])
.range([0, radius]);
const color = d3.scaleOrdinal()
.domain(radarData.map(d => d.genre))
.range(d3.schemeSet2);
// Líneas de la grilla
const levels = 5;
for (let level = 1; level <= levels; level++) {
const levelRadius = radius * (level / levels);
g.append("circle")
.attr("r", levelRadius)
.style("fill", "none")
.style("stroke", "#CDCDCD")
.style("stroke-width", "0.5px");
g.append("text")
.attr("x", 4)
.attr("y", -levelRadius)
.style("font-size", "10px")
.style("fill", "#737373")
.text((10 * level / levels).toFixed(0));
}
// Ejes
axes.forEach((axis, i) => {
const angle = angleSlice * i - Math.PI / 2;
const lineCoords = {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius
};
g.append("line")
.attr("x1", 0)
.attr("y1", 0)
.attr("x2", lineCoords.x)
.attr("y2", lineCoords.y)
.style("stroke", "#CDCDCD")
.style("stroke-width", "1px");
g.append("text")
.attr("x", lineCoords.x * 1.15)
.attr("y", lineCoords.y * 1.15)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.style("font-size", "12px")
.style("font-weight", "bold")
.text({
total: "Volumen",
notable: "Notables",
recent: "Recientes",
collaborations: "Colaborativas"
}[axis]);
});
// Crear path para cada género
radarData.forEach((data, index) => {
const radarLine = d3.lineRadial()
.angle((d, i) => angleSlice * i)
.radius(d => rScale(d))
.curve(d3.curveCardinalClosed);
const values = axes.map(axis => data[axis]);
g.append("path")
.datum(values)
.attr("d", radarLine)
.style("fill", color(data.genre))
.style("fill-opacity", 0.2)
.style("stroke", color(data.genre))
.style("stroke-width", 2);
// Puntos
axes.forEach((axis, i) => {
const angle = angleSlice * i - Math.PI / 2;
const x = Math.cos(angle) * rScale(data[axis]);
const y = Math.sin(angle) * rScale(data[axis]);
g.append("circle")
.attr("cx", x)
.attr("cy", y)
.attr("r", 3)
.style("fill", color(data.genre))
.style("stroke", "#fff")
.style("stroke-width", 1);
});
});
// Leyenda
const legend = svg.append("g")
.attr("transform", `translate(${width + 20}, 50)`);
radarData.forEach((data, i) => {
const legendRow = legend.append("g")
.attr("transform", `translate(0, ${i * 25})`);
legendRow.append("rect")
.attr("width", 15)
.attr("height", 15)
.style("fill", color(data.genre));
legendRow.append("text")
.attr("x", 20)
.attr("y", 12)
.style("font-size", "12px")
.text(data.genre);
});
// Título
svg.append("text")
.attr("x", (width + 300) / 2)
.attr("y", 25)
.attr("text-anchor", "middle")
.style("font-size", "16px")
.style("font-weight", "bold")
.text("📊 Perfil de Géneros Musicales");
return svg.node();
}
Insert cell
// === CELDA 7: Dashboard Interactivo de Sailor Shift ===
viewof sailorDashboard = {
const container = html`<div id="sailor-dashboard"></div>`;
// Buscar datos de Sailor Shift
const sailorNodes = nodes.filter(n =>
(n.name && n.name.toLowerCase().includes("sailor")) ||
(n.stage_name && n.stage_name.toLowerCase().includes("sailor"))
);
const sailorIds = new Set(sailorNodes.map(n => n.id));
const sailorConnections = links.filter(l =>
sailorIds.has(l.source) || sailorIds.has(l.target)
);
// Análisis de influencias (quién influyó a Sailor)
const influences = sailorConnections
.filter(l => ["InStyleOf", "InterpolatesFrom", "CoverOf"].includes(l["Edge Type"]))
.map(l => {
const influencer = sailorIds.has(l.target) ?
nodes.find(n => n.id === l.source) :
nodes.find(n => n.id === l.target);
return influencer;
})
.filter(Boolean);
// Colaboraciones
const collaborations = sailorConnections
.filter(l => l["Edge Type"] === "PerformerOf")
.map(l => {
const collaborator = sailorIds.has(l.source) ?
nodes.find(n => n.id === l.target) :
nodes.find(n => n.id === l.source);
return collaborator;
})
.filter(Boolean);
const width = 900;
const height = 600;
const svg = d3.select(container)
.append("svg")
.attr("width", width)
.attr("height", height);
// Estado interactivo
let selectedCategory = "influences";
function updateVisualization() {
svg.selectAll("*").remove();
let currentData;
let title;
switch(selectedCategory) {
case "influences":
currentData = influences.slice(0, 10);
title = "🎵 Principales Influencias de Sailor Shift";
break;
case "collaborations":
currentData = collaborations.slice(0, 10);
title = "🤝 Colaboraciones de Sailor Shift";
break;
case "impact":
currentData = nodes.filter(n => n.genre === "Oceanus Folk").slice(0, 10);
title = "🌊 Impacto en la Comunidad Oceanus Folk";
break;
}
// Crear visualización de red centrada
const simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(100))
.force("charge", d3.forceManyBody().strength(-300))
.force("center", d3.forceCenter(width / 2, height / 2));
// Nodo central (Sailor Shift)
const centerNode = {id: "sailor", name: "Sailor Shift", type: "center"};
const networkNodes = [centerNode, ...currentData.map((d, i) => ({
...d,
id: d.id || `node-${i}`,
name: d.name || "Sin nombre"
}))];
const networkLinks = currentData.map((d, i) => ({
source: "sailor",
target: d.id || `node-${i}`
}));
// Enlaces
const link = svg.append("g")
.selectAll("line")
.data(networkLinks)
.enter().append("line")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.attr("stroke-width", 2);
// Nodos
const node = svg.append("g")
.selectAll("circle")
.data(networkNodes)
.enter().append("circle")
.attr("r", d => d.type === "center" ? 20 : 12)
.attr("fill", d => {
if (d.type === "center") return "#e74c3c";
if (d["Node Type"] === "Song") return "#3498db";
if (d["Node Type"] === "Album") return "#2ecc71";
if (d["Node Type"] === "Person") return "#f39c12";
return "#95a5a6";
})
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function(event, d) {
d3.select(this).attr("r", d.type === "center" ? 25 : 15);
// Tooltip
const tooltip = svg.append("g").attr("id", "tooltip");
const rect = tooltip.append("rect")
.attr("x", event.layerX + 10)
.attr("y", event.layerY - 10)
.attr("width", 150)
.attr("height", 60)
.attr("fill", "white")
.attr("stroke", "#333")
.attr("rx", 5);
tooltip.append("text")
.attr("x", event.layerX + 15)
.attr("y", event.layerY + 10)
.style("font-size", "12px")
.style("font-weight", "bold")
.text(d.name);
tooltip.append("text")
.attr("x", event.layerX + 15)
.attr("y", event.layerY + 25)
.style("font-size", "10px")
.text(`Tipo: ${d["Node Type"] || d.type}`);
if (d.genre) {
tooltip.append("text")
.attr("x", event.layerX + 15)
.attr("y", event.layerY + 40)
.style("font-size", "10px")
.text(`Género: ${d.genre}`);
}
})
.on("mouseout", function(event, d) {
d3.select(this).attr("r", d.type === "center" ? 20 : 12);
svg.select("#tooltip").remove();
})
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
// Etiquetas
const text = svg.append("g")
.selectAll("text")
.data(networkNodes)
.enter().append("text")
.attr("dx", 15)
.attr("dy", "0.35em")
.style("font-size", d => d.type === "center" ? "14px" : "10px")
.style("font-weight", d => d.type === "center" ? "bold" : "normal")
.text(d => d.name.length > 15 ? d.name.substring(0, 15) + "..." : d.name);
// Título
svg.append("text")
.attr("x", width / 2)
.attr("y", 30)
.attr("text-anchor", "middle")
.style("font-size", "18px")
.style("font-weight", "bold")
.text(title);
simulation.nodes(networkNodes);
simulation.force("link").links(networkLinks);
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
text
.attr("x", d => d.x)
.attr("y", d => d.y);
});
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
}
// Controles interactivos
const controls = d3.select(container)
.insert("div", "svg")
.style("margin-bottom", "20px")
.style("text-align", "center");
controls.append("button")
.text("🎵 Influencias")
.style("margin", "5px")
.style("padding", "10px 15px")
.style("border", "none")
.style("border-radius", "5px")
.style("background", "#3498db")
.style("color", "white")
.style("cursor", "pointer")
.on("click", () => {
selectedCategory = "influences";
updateVisualization();
});
controls.append("button")
.text("🤝 Colaboraciones")
.style("margin", "5px")
.style("padding", "10px 15px")
.style("border", "none")
.style("border-radius", "5px")
.style("background", "#2ecc71")
.style("color", "white")
.style("cursor", "pointer")
.on("click", () => {
selectedCategory = "collaborations";
updateVisualization();
});
controls.append("button")
.text("🌊 Impacto")
.style("margin", "5px")
.style("padding", "10px 15px")
.style("border", "none")
.style("border-radius", "5px")
.style("background", "#e74c3c")
.style("color", "white")
.style("cursor", "pointer")
.on("click", () => {
selectedCategory = "impact";
updateVisualization();
});
updateVisualization();
return container;
}
Insert cell
// === CELDA 8: Timeline Interactivo Oceanus Folk ===
viewof oceanusTimeline = {
const container = html`<div id="oceanus-timeline"></div>`;
const oceanusFolk = nodes.filter(n =>
n.genre && n.genre.toLowerCase().includes("oceanus folk")
);
const yearData = d3.group(oceanusFolk, d => d.release_date);
const timelineData = Array.from(yearData, ([year, works]) => ({
year: +year,
total: works.length,
notable: works.filter(w => w.notable).length,
songs: works.filter(w => w["Node Type"] === "Song").length,
albums: works.filter(w => w["Node Type"] === "Album").length,
works: works
})).filter(d => d.year).sort((a, b) => a.year - b.year);
const width = 1000;
const height = 400;
const margin = {top: 50, right: 150, bottom: 50, left: 50};
const svg = d3.select(container)
.append("svg")
.attr("width", width)
.attr("height", height);
const x = d3.scaleLinear()
.domain(d3.extent(timelineData, d => d.year))
.range([margin.left, width - margin.right]);
const y = d3.scaleLinear()
.domain([0, d3.max(timelineData, d => d.total)])
.range([height - margin.bottom, margin.top]);
// Área de fondo
const area = d3.area()
.x(d => x(d.year))
.y0(height - margin.bottom)
.y1(d => y(d.total))
.curve(d3.curveMonotoneX);
svg.append("path")
.datum(timelineData)
.attr("fill", "rgba(52, 152, 219, 0.3)")
.attr("d", area);
// Línea principal
const line = d3.line()
.x(d => x(d.year))
.y(d => y(d.total))
.curve(d3.curveMonotoneX);
svg.append("path")
.datum(timelineData)
.attr("fill", "none")
.attr("stroke", "#3498db")
.attr("stroke-width", 3)
.attr("d", line);
// Puntos interactivos
const circles = svg.selectAll(".timeline-point")
.data(timelineData)
.enter().append("circle")
.attr("class", "timeline-point")
.attr("cx", d => x(d.year))
.attr("cy", d => y(d.total))
.attr("r", 6)
.attr("fill", "#e74c3c")
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.on("mouseover", function(event, d) {
d3.select(this).attr("r", 10);
showTooltip(event, d);
})
.on("mouseout", function(event, d) {
d3.select(this).attr("r", 6);
hideTooltip();
})
.on("click", function(event, d) {
showDetails(d);
});
// Ejes
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickFormat(d3.format("d")));
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y));
// Título
svg.append("text")
.attr("x", width / 2)
.attr("y", 25)
.attr("text-anchor", "middle")
.style("font-size", "18px")
.style("font-weight", "bold")
.text("🌊 Expansión del Oceanus Folk a través del Tiempo");
// Panel de detalles
const detailsPanel = d3.select(container)
.append("div")
.attr("id", "details-panel")
.style("margin-top", "20px")
.style("padding", "15px")
.style("background", "#f8f9fa")
.style("border-radius", "10px")
.style("display", "none");
function showTooltip(event, d) {
const tooltip = svg.append("g").attr("id", "timeline-tooltip");
const rect = tooltip.append("rect")
.attr("x", x(d.year) + 10)
.attr("y", y(d.total) - 50)
.attr("width", 120)
.attr("height", 80)
.attr("fill", "white")
.attr("stroke", "#333")
.attr("rx", 5);
tooltip.append("text")
.attr("x", x(d.year) + 15)
.attr("y", y(d.total) - 35)
.style("font-size", "12px")
.style("font-weight", "bold")
.text(`Año: ${d.year}`);
tooltip.append("text")
.attr("x", x(d.year) + 15)
.attr("y", y(d.total) - 20)
.style("font-size", "10px")
.text(`Total: ${d.total}`);
tooltip.append("text")
.attr("x", x(d.year) + 15)
.attr("y", y(d.total) - 5)
.style("font-size", "10px")
.text(`Notables: ${d.notable}`);
tooltip.append("text")
.attr("x", x(d.year) + 15)
.attr("y", y(d.total) + 10)
.style("font-size", "8px")
.style("fill", "#666")
.text("Click para detalles");
}
function hideTooltip() {
svg.select("#timeline-tooltip").remove();
}
function showDetails(d) {
detailsPanel.style("display", "block");
detailsPanel.html(`
<h3>📅 Año ${d.year} - Detalles del Oceanus Folk</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
<div>
<h4>📊 Estadísticas</h4>
<p>Total de obras: <strong>${d.total}</strong></p>
<p>Obras notables: <strong>${d.notable}</strong></p>
<p>Canciones: <strong>${d.songs}</strong></p>
<p>Álbumes: <strong>${d.albums}</strong></p>
</div>
<div>
<h4>🎵 Obras Destacadas</h4>
${d.works.filter(w => w.notable).slice(0, 5).map(work =>
`<p style="margin: 5px 0;"><strong>${work.name || 'Sin nombre'}</strong> (${work["Node Type"]})</p>`
).join('')}
</div>
</div>
`);
}
// Análisis de tendencias
const trendAnalysis = d3.select(container)
.append("div")
.style("margin-top", "20px")
.style("padding", "15px")
.style("background", "#e8f5e8")
.style("border-radius", "10px");
const avgGrowth = d3.mean(timelineData.slice(1).map((d, i) =>
d.total - timelineData[i].total
));
trendAnalysis.html(`
<h4>📈 Análisis de Tendencias</h4>
<p><strong>Crecimiento promedio anual:</strong> ${avgGrowth.toFixed(1)} obras por año</p>
<p><strong>Año pico:</strong> ${timelineData.find(d => d.total === d3.max(timelineData, d => d.total)).year}</p>
<p><strong>Patrón:</strong> ${avgGrowth > 0 ? 'Crecimiento gradual sostenido' : 'Crecimiento intermitente'}</p>
`);
return container;
}
Insert cell
// === CELDA 11: Análisis de Carreras con Selector Interactivo ===
viewof starAnalysisInteractive = {
const container = html`<div id="star-analysis-interactive"></div>`;
// Identificar artistas con métricas de estrella (mismo cálculo anterior)
const artists = nodes.filter(d => d["Node Type"] === "Person");
const artistMetrics = artists.map(artist => {
const connections = links.filter(l => l.source === artist.id || l.target === artist.id);
const works = nodes.filter(work =>
connections.some(conn =>
(conn.source === artist.id && conn.target === work.id &&
["PerformerOf", "ComposerOf"].includes(conn["Edge Type"])) ||
(conn.target === artist.id && conn.source === work.id &&
["PerformerOf", "ComposerOf"].includes(conn["Edge Type"]))
) && (work["Node Type"] === "Song" || work["Node Type"] === "Album")
);
const worksWithDates = works.filter(w => w.release_date);
const notableWorks = works.filter(w => w.notable);
const recentWorks = works.filter(w => w.release_date && +w.release_date >= 2035);
const collaborations = connections.filter(c => c["Edge Type"] === "PerformerOf").length;
const influencesReceived = connections.filter(c =>
["InStyleOf", "InterpolatesFrom"].includes(c["Edge Type"]) && c.target === artist.id
).length;
const influencesGiven = connections.filter(c =>
["InStyleOf", "InterpolatesFrom"].includes(c["Edge Type"]) && c.source === artist.id
).length;
const yearGroups = d3.group(worksWithDates, w => +w.release_date);
const yearlyOutput = Array.from(yearGroups, ([year, works]) => ({
year,
count: works.length,
notable: works.filter(w => w.notable).length
})).sort((a, b) => a.year - b.year);
const recentYears = yearlyOutput.slice(-5);
const earlyYears = yearlyOutput.slice(0, 5);
const recentAvg = d3.mean(recentYears, d => d.count) || 0;
const earlyAvg = d3.mean(earlyYears, d => d.count) || 0;
const growthTrend = recentAvg - earlyAvg;
const starScore = (
(notableWorks.length * 4) +
(recentWorks.length * 3) +
(collaborations * 1.5) +
(influencesGiven * 2) +
(growthTrend * 2) +
(works.length * 0.5)
);
return {
id: artist.id,
name: artist.name || artist.stage_name || 'Desconocido',
totalWorks: works.length,
notableWorks: notableWorks.length,
recentWorks: recentWorks.length,
collaborations,
influencesReceived,
influencesGiven,
growthTrend,
starScore,
yearlyOutput,
works,
notableRatio: works.length > 0 ? notableWorks.length / works.length : 0,
activeYears: worksWithDates.length > 0 ?
d3.max(worksWithDates, w => +w.release_date) - d3.min(worksWithDates, w => +w.release_date) + 1 : 0
};
}).filter(a => a.starScore > 5).sort((a, b) => b.starScore - a.starScore);
// Estado de la aplicación
let selectedArtists = artistMetrics.slice(0, 3);
// Crear el selector de artistas
const selectorContainer = d3.select(container)
.append("div")
.style("background", "#f8f9fa")
.style("padding", "20px")
.style("border-radius", "10px")
.style("margin-bottom", "20px");
selectorContainer.append("h3")
.style("margin", "0 0 15px 0")
.style("color", "#2c3e50")
.text("🎯 Selector de Artistas para Comparar");
const instructionText = selectorContainer.append("p")
.style("margin", "0 0 15px 0")
.style("color", "#7f8c8d")
.style("font-size", "14px")
.text("Selecciona hasta 3 artistas para comparar sus carreras. Click en un artista seleccionado para removerlo.");
// Grid de artistas disponibles
const artistGrid = selectorContainer.append("div")
.style("display", "grid")
.style("grid-template-columns", "repeat(auto-fill, minmax(250px, 1fr))")
.style("gap", "10px")
.style("max-height", "300px")
.style("overflow-y", "auto")
.style("border", "1px solid #ddd")
.style("border-radius", "5px")
.style("padding", "10px")
.style("background", "white");
function updateArtistGrid() {
const artistCards = artistGrid.selectAll(".artist-card")
.data(artistMetrics.slice(0, 20), d => d.id);
const cardEnter = artistCards.enter()
.append("div")
.attr("class", "artist-card")
.style("padding", "10px")
.style("border", "1px solid #ddd")
.style("border-radius", "5px")
.style("cursor", "pointer")
.style("transition", "all 0.3s ease")
.on("mouseover", function() {
d3.select(this).style("box-shadow", "0 2px 8px rgba(0,0,0,0.15)");
})
.on("mouseout", function() {
d3.select(this).style("box-shadow", "none");
})
.on("click", function(event, d) {
toggleArtistSelection(d);
});
const cardUpdate = cardEnter.merge(artistCards);
cardUpdate
.style("background", d => selectedArtists.find(a => a.id === d.id) ? "#e8f5e8" : "white")
.style("border-color", d => selectedArtists.find(a => a.id === d.id) ? "#27ae60" : "#ddd")
.html(d => {
const isSelected = selectedArtists.find(a => a.id === d.id);
return `
<div style="display: flex; justify-content: between; align-items: center;">
<div style="flex: 1;">
<div style="font-weight: bold; color: #2c3e50;">${isSelected ? '✓ ' : ''}${d.name}</div>
<div style="font-size: 12px; color: #7f8c8d; margin-top: 5px;">
Score: ${d.starScore.toFixed(1)} | Obras: ${d.totalWorks} | Notables: ${d.notableWorks}
</div>
</div>
${isSelected ? '<div style="color: #27ae60; font-size: 18px;">✓</div>' : ''}
</div>
`;
});
artistCards.exit().remove();
}
function toggleArtistSelection(artist) {
const index = selectedArtists.findIndex(a => a.id === artist.id);
if (index >= 0) {
// Remover artista
selectedArtists.splice(index, 1);
} else if (selectedArtists.length < 3) {
// Agregar artista
selectedArtists.push(artist);
} else {
// Reemplazar el último si ya hay 3
selectedArtists[2] = artist;
}
updateArtistGrid();
updateVisualizations();
}
// Contenedor para visualizaciones
const vizContainer = d3.select(container)
.append("div")
.attr("id", "visualizations-container");
function updateVisualizations() {
// Limpiar visualizaciones anteriores
vizContainer.selectAll("*").remove();
if (selectedArtists.length === 0) {
vizContainer.append("div")
.style("text-align", "center")
.style("padding", "50px")
.style("color", "#7f8c8d")
.text("Selecciona al menos un artista para ver las visualizaciones");
return;
}
// Dashboard principal
const dashboard = vizContainer.append("div")
.style("display", "grid")
.style("grid-template-columns", "1fr 1fr")
.style("gap", "20px");
// Panel izquierdo: Radar Chart
const leftPanel = dashboard.append("div");
const svg1 = leftPanel.append("svg")
.attr("width", 600)
.attr("height", 400);
// Datos para radar
const radarData = selectedArtists.map(artist => ({
name: artist.name,
metrics: [
{axis: "Obras Totales", value: Math.min(artist.totalWorks / 10, 10)},
{axis: "Obras Notables", value: Math.min(artist.notableWorks * 2, 10)},
{axis: "Colaboraciones", value: Math.min(artist.collaborations / 5, 10)},
{axis: "Influencia Dada", value: Math.min(artist.influencesGiven * 3, 10)},
{axis: "Crecimiento", value: Math.min((artist.growthTrend + 5) * 2, 10)},
{axis: "Ratio Éxito", value: artist.notableRatio * 10}
]
}));
// Crear radar chart
const radarMargin = 50;
const radarRadius = 150;
const radarCenter = {x: 300, y: 200};
const angleSlice = Math.PI * 2 / 6;
const radarScale = d3.scaleLinear().domain([0, 10]).range([0, radarRadius]);
// Líneas de grilla
for (let level = 1; level <= 5; level++) {
svg1.append("circle")
.attr("cx", radarCenter.x)
.attr("cy", radarCenter.y)
.attr("r", radarRadius * level / 5)
.style("fill", "none")
.style("stroke", "#CDCDCD")
.style("stroke-width", "0.5px");
}
// Ejes del radar
radarData[0].metrics.forEach((metric, i) => {
const angle = angleSlice * i - Math.PI / 2;
const lineCoords = {
x: radarCenter.x + Math.cos(angle) * radarRadius,
y: radarCenter.y + Math.sin(angle) * radarRadius
};
svg1.append("line")
.attr("x1", radarCenter.x)
.attr("y1", radarCenter.y)
.attr("x2", lineCoords.x)
.attr("y2", lineCoords.y)
.style("stroke", "#CDCDCD")
.style("stroke-width", "1px");
svg1.append("text")
.attr("x", lineCoords.x + Math.cos(angle) * 20)
.attr("y", lineCoords.y + Math.sin(angle) * 20)
.attr("text-anchor", "middle")
.style("font-size", "10px")
.style("font-weight", "bold")
.text(metric.axis);
});
// Colores dinámicos según número de artistas seleccionados
const colors = ["#e74c3c", "#3498db", "#2ecc71"];
// Crear líneas para cada artista
radarData.forEach((artist, artistIndex) => {
const radarLine = d3.lineRadial()
.angle((d, i) => angleSlice * i)
.radius(d => radarScale(d.value))
.curve(d3.curveCardinalClosed);
const values = artist.metrics.map(m => m.value);
svg1.append("path")
.datum(values)
.attr("d", radarLine)
.attr("transform", `translate(${radarCenter.x}, ${radarCenter.y})`)
.style("fill", colors[artistIndex])
.style("fill-opacity", 0.2)
.style("stroke", colors[artistIndex])
.style("stroke-width", 2);
// Puntos
artist.metrics.forEach((metric, i) => {
const angle = angleSlice * i - Math.PI / 2;
const x = radarCenter.x + Math.cos(angle) * radarScale(metric.value);
const y = radarCenter.y + Math.sin(angle) * radarScale(metric.value);
svg1.append("circle")
.attr("cx", x)
.attr("cy", y)
.attr("r", 3)
.style("fill", colors[artistIndex])
.style("stroke", "#fff")
.style("stroke-width", 1);
});
});
// Título del radar
svg1.append("text")
.attr("x", 300)
.attr("y", 25)
.attr("text-anchor", "middle")
.style("font-size", "16px")
.style("font-weight", "bold")
.text("🎯 Perfil Comparativo de Estrellas");
// Panel derecho: Timeline
const rightPanel = dashboard.append("div");
const svg2 = rightPanel.append("svg")
.attr("width", 600)
.attr("height", 400);
// Timeline de evolución
const timelineMargin = {top: 30, right: 50, bottom: 50, left: 50};
const timelineWidth = 600 - timelineMargin.left - timelineMargin.right;
const timelineHeight = 400 - timelineMargin.top - timelineMargin.bottom;
const allYears = selectedArtists.flatMap(a => a.yearlyOutput.map(y => y.year));
const yearExtent = d3.extent(allYears);
if (yearExtent[0]) {
const xScale = d3.scaleLinear()
.domain(yearExtent)
.range([timelineMargin.left, timelineWidth + timelineMargin.left]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(selectedArtists.flatMap(a => a.yearlyOutput), d => d.count)])
.range([timelineHeight + timelineMargin.top, timelineMargin.top]);
// Líneas de timeline para cada artista
selectedArtists.forEach((artist, index) => {
const line = d3.line()
.x(d => xScale(d.year))
.y(d => yScale(d.count))
.curve(d3.curveMonotoneX);
svg2.append("path")
.datum(artist.yearlyOutput)
.attr("fill", "none")
.attr("stroke", colors[index])
.attr("stroke-width", 3)
.attr("d", line);
// Puntos notables
svg2.selectAll(`.notable-${index}`)
.data(artist.yearlyOutput.filter(d => d.notable > 0))
.enter().append("circle")
.attr("class", `notable-${index}`)
.attr("cx", d => xScale(d.year))
.attr("cy", d => yScale(d.count))
.attr("r", d => 3 + d.notable)
.attr("fill", colors[index])
.attr("stroke", "#fff")
.attr("stroke-width", 2);
});
// Ejes del timeline
svg2.append("g")
.attr("transform", `translate(0,${timelineHeight + timelineMargin.top})`)
.call(d3.axisBottom(xScale).tickFormat(d3.format("d")));
svg2.append("g")
.attr("transform", `translate(${timelineMargin.left},0)`)
.call(d3.axisLeft(yScale));
}
// Título del timeline
svg2.append("text")
.attr("x", 300)
.attr("y", 20)
.attr("text-anchor", "middle")
.style("font-size", "16px")
.style("font-weight", "bold")
.text("📈 Evolución Temporal de Carreras");
// Leyenda
const legend = vizContainer.append("div")
.style("display", "flex")
.style("justify-content", "center")
.style("margin", "20px 0")
.style("gap", "30px");
selectedArtists.forEach((artist, index) => {
const legendItem = legend.append("div")
.style("display", "flex")
.style("align-items", "center")
.style("gap", "10px");
legendItem.append("div")
.style("width", "20px")
.style("height", "20px")
.style("background", colors[index])
.style("border-radius", "3px");
legendItem.append("span")
.style("font-weight", "bold")
.text(`${artist.name} (Score: ${artist.starScore.toFixed(1)})`);
});
// Cards de análisis detallado
const detailsSection = vizContainer.append("div")
.style("margin-top", "30px");
detailsSection.append("h3")
.style("text-align", "center")
.style("color", "#2c3e50")
.text("🔍 Análisis Detallado de Carreras Seleccionadas");
const artistCards = detailsSection.append("div")
.style("display", "grid")
.style("grid-template-columns", "repeat(auto-fit, minmax(350px, 1fr))")
.style("gap", "20px");
selectedArtists.forEach((artist, index) => {
const card = artistCards.append("div")
.style("background", "#f8f9fa")
.style("padding", "20px")
.style("border-radius", "10px")
.style("border-left", `5px solid ${colors[index]}`);
card.append("h4")
.style("margin", "0 0 15px 0")
.style("color", "#2c3e50")
.text(`${index + 1}. ${artist.name}`);
const metrics = card.append("div")
.style("display", "grid")
.style("grid-template-columns", "1fr 1fr")
.style("gap", "10px")
.style("margin-bottom", "15px");
metrics.append("div").html(`<strong>📊 Score Total:</strong> ${artist.starScore.toFixed(1)}`);
metrics.append("div").html(`<strong>🎵 Obras:</strong> ${artist.totalWorks}`);
metrics.append("div").html(`<strong>⭐ Notables:</strong> ${artist.notableWorks}`);
metrics.append("div").html(`<strong>🤝 Colaboraciones:</strong> ${artist.collaborations}`);
metrics.append("div").html(`<strong>📈 Tendencia:</strong> ${artist.growthTrend > 0 ? '↗️' : '↘️'} ${artist.growthTrend.toFixed(1)}`);
metrics.append("div").html(`<strong>🎯 Ratio Éxito:</strong> ${(artist.notableRatio * 100).toFixed(1)}%`);
// Características de estrella
const characteristics = [];
if (artist.notableRatio > 0.3) characteristics.push("🌟 Alta calidad");
if (artist.growthTrend > 1) characteristics.push("📈 Crecimiento sostenido");
if (artist.collaborations > 10) characteristics.push("🤝 Muy colaborativo");
if (artist.influencesGiven > 3) characteristics.push("🎭 Influyente");
if (artist.recentWorks > 5) characteristics.push("🔥 Muy activo");
if (characteristics.length > 0) {
card.append("div")
.style("margin-top", "10px")
.style("padding", "10px")
.style("background", "white")
.style("border-radius", "5px")
.html(`<strong>Características de Estrella:</strong><br>${characteristics.join(" • ")}`);
}
});
}
// Inicializar
updateArtistGrid();
updateVisualizations();
return container;
}
Insert cell
// === CELDA 17: Dashboard de Predicciones Dinámico ===
viewof dynamicPredictionDashboard = {
const container = html`<div id="dynamic-prediction-dashboard"></div>`;
// Calcular métricas de predicción mejoradas
const oceanusArtists = [];
nodes.filter(n => n.genre === "Oceanus Folk").forEach(work => {
const workConnections = links.filter(l =>
(l.source === work.id || l.target === work.id) &&
["PerformerOf", "ComposerOf", "LyricistOf"].includes(l["Edge Type"])
);
workConnections.forEach(conn => {
const artistId = conn.source === work.id ? conn.target : conn.source;
const artist = nodes.find(n => n.id === artistId && n["Node Type"] === "Person");
if (artist && !oceanusArtists.find(a => a.id === artist.id)) {
oceanusArtists.push(artist);
}
});
});
const predictions = oceanusArtists.map(artist => {
const connections = links.filter(l => l.source === artist.id || l.target === artist.id);
const works = nodes.filter(work =>
connections.some(conn =>
(conn.source === artist.id && conn.target === work.id) ||
(conn.target === artist.id && conn.source === work.id)
) && (work["Node Type"] === "Song" || work["Node Type"] === "Album")
);
const oceanusWorks = works.filter(w => w.genre === "Oceanus Folk");
const recentWorks = works.filter(w => w.release_date && +w.release_date >= 2037);
const notableWorks = works.filter(w => w.notable);
// Calcular momentum
const yearGroups = d3.group(works.filter(w => w.release_date), w => +w.release_date);
const recentYears = Array.from(yearGroups.keys()).filter(year => year >= 2035).sort();
const momentum = recentYears.length > 1 ?
(yearGroups.get(recentYears[recentYears.length - 1])?.length || 0) -
(yearGroups.get(recentYears[0])?.length || 0) : 0;
// Colaboraciones con artistas establecidos
const establishedCollabs = connections.filter(conn => {
const otherId = conn.source === artist.id ? conn.target : conn.source;
const other = nodes.find(n => n.id === otherId);
return other && other["Node Type"] === "Person" &&
links.filter(l => l.source === otherId || l.target === otherId).length > 20;
}).length;
const genres = new Set(works.map(w => w.genre).filter(Boolean));
const modernInfluences = links.filter(l =>
l.source === artist.id &&
["InStyleOf", "InterpolatesFrom"].includes(l["Edge Type"]) &&
nodes.find(n => n.id === l.target && n.release_date && +n.release_date >= 2035)
).length;
const oceanusRatio = works.length > 0 ? oceanusWorks.length / works.length : 0;
const notableRatio = works.length > 0 ? notableWorks.length / works.length : 0;
// Score mejorado con factores temporales
const currentYear = 2040;
const careerLength = works.length > 0 ?
Math.max(...works.filter(w => w.release_date).map(w => +w.release_date)) -
Math.min(...works.filter(w => w.release_date).map(w => +w.release_date)) + 1 : 1;
const experienceFactor = Math.min(careerLength / 10, 1); // Factor de experiencia
const freshnessFactor = recentWorks.length > 0 ? 1.2 : 0.8; // Factor de frescura
const futureStarScore = (
(oceanusRatio * 10) +
(notableRatio * 8) +
(momentum * 6 * freshnessFactor) +
(recentWorks.length * 3) +
(establishedCollabs * 4) +
(genres.size * 2) +
(modernInfluences * 7) +
(works.length * 0.3 * experienceFactor)
);
// Calcular confianza basada en consistencia
const consistency = recentYears.length > 2 ?
1 - (d3.deviation(recentYears.map(year => yearGroups.get(year)?.length || 0)) || 0) / 5 : 0.5;
const confidence = Math.min(95, 30 + (futureStarScore * 2) + (consistency * 20));
return {
id: artist.id,
name: artist.name || artist.stage_name || 'Desconocido',
futureStarScore,
confidence,
oceanusRatio,
notableRatio,
momentum,
recentWorks: recentWorks.length,
establishedCollabs,
modernInfluences,
careerLength,
consistency,
totalWorks: works.length,
prediction: futureStarScore > 15 ? "Muy Probable" :
futureStarScore > 10 ? "Probable" :
futureStarScore > 5 ? "Posible" : "Improbable"
};
}).filter(p => p.futureStarScore > 3)
.sort((a, b) => b.futureStarScore - a.futureStarScore);
const top3Predictions = predictions.slice(0, 3);
// Crear dashboard
const dashboard = d3.select(container)
.style("background", "linear-gradient(135deg, #667eea 0%, #764ba2 100%)")
.style("padding", "20px")
.style("border-radius", "15px")
.style("color", "white");
// Título principal con animación
const title = dashboard.append("h2")
.style("text-align", "center")
.style("margin", "0 0 30px 0")
.style("font-size", "28px")
.style("text-shadow", "2px 2px 4px rgba(0,0,0,0.5)")
.text("🔮 Predictor Dinámico: Futuras Estrellas de Oceanus Folk");
// Métricas generales
const statsContainer = dashboard.append("div")
.style("display", "grid")
.style("grid-template-columns", "repeat(auto-fit, minmax(200px, 1fr))")
.style("gap", "15px")
.style("margin-bottom", "30px");
const totalCandidates = predictions.length;
const highConfidence = predictions.filter(p => p.confidence > 70).length;
const avgScore = d3.mean(predictions, p => p.futureStarScore);
const stats = [
{label: "Candidatos Totales", value: totalCandidates, icon: "🎤"},
{label: "Alta Confianza", value: highConfidence, icon: "⭐"},
{label: "Score Promedio", value: avgScore.toFixed(1), icon: "📊"},
{label: "Predicciones para", value: "2025-2030", icon: "🗓️"}
];
stats.forEach(stat => {
const statCard = statsContainer.append("div")
.style("background", "rgba(255,255,255,0.1)")
.style("padding", "15px")
.style("border-radius", "10px")
.style("text-align", "center")
.style("backdrop-filter", "blur(10px)")
.style("border", "1px solid rgba(255,255,255,0.2)");
statCard.append("div")
.style("font-size", "24px")
.style("margin-bottom", "5px")
.text(stat.icon);
statCard.append("div")
.style("font-size", "20px")
.style("font-weight", "bold")
.style("margin-bottom", "5px")
.text(stat.value);
statCard.append("div")
.style("font-size", "12px")
.style("opacity", "0.8")
.text(stat.label);
});
// Panel principal con Top 3
const mainPanel = dashboard.append("div")
.style("display", "grid")
.style("grid-template-columns", "2fr 1fr")
.style("gap", "30px")
.style("margin-bottom", "30px");
// Lado izquierdo: Visualización radar animada
const leftPanel = mainPanel.append("div");
const radarContainer = leftPanel.append("div")
.style("background", "rgba(255,255,255,0.1)")
.style("border-radius", "15px")
.style("padding", "20px")
.style("backdrop-filter", "blur(10px)");
radarContainer.append("h3")
.style("text-align", "center")
.style("margin", "0 0 20px 0")
.text("📊 Perfil Predictivo - Top 3");
const radarSvg = radarContainer.append("svg")
.attr("width", 500)
.attr("height", 400);
// Crear radar animado
const radarMetrics = [
"Especialización OF",
"Calidad",
"Momentum",
"Actividad",
"Red Establecida",
"Influencia Moderna"
];
const radarData = top3Predictions.map(artist => ({
name: artist.name,
values: [
artist.oceanusRatio * 10,
artist.notableRatio * 10,
Math.min((artist.momentum + 3) * 2, 10),
Math.min(artist.recentWorks * 1.5, 10),
Math.min(artist.establishedCollabs * 2.5, 10),
Math.min(artist.modernInfluences * 3, 10)
],
score: artist.futureStarScore,
confidence: artist.confidence
}));
function createAnimatedRadar() {
const radarCenter = {x: 250, y: 200};
const radarRadius = 150;
const angleSlice = Math.PI * 2 / radarMetrics.length;
const radarScale = d3.scaleLinear().domain([0, 10]).range([0, radarRadius]);
// Limpiar contenido anterior
radarSvg.selectAll("*").remove();
// Grilla animada
for (let level = 1; level <= 5; level++) {
radarSvg.append("circle")
.attr("cx", radarCenter.x)
.attr("cy", radarCenter.y)
.attr("r", 0)
.style("fill", "none")
.style("stroke", "rgba(255,255,255,0.3)")
.style("stroke-width", "1px")
.transition()
.delay(level * 100)
.duration(500)
.attr("r", radarRadius * level / 5);
}
// Ejes animados
radarMetrics.forEach((metric, i) => {
const angle = angleSlice * i - Math.PI / 2;
const lineCoords = {
x: radarCenter.x + Math.cos(angle) * radarRadius,
y: radarCenter.y + Math.sin(angle) * radarRadius
};
const line = radarSvg.append("line")
.attr("x1", radarCenter.x)
.attr("y1", radarCenter.y)
.attr("x2", radarCenter.x)
.attr("y2", radarCenter.y)
.style("stroke", "rgba(255,255,255,0.5)")
.style("stroke-width", "1px");
line.transition()
.delay(200 + i * 50)
.duration(300)
.attr("x2", lineCoords.x)
.attr("y2", lineCoords.y);
radarSvg.append("text")
.attr("x", lineCoords.x + Math.cos(angle) * 25)
.attr("y", lineCoords.y + Math.sin(angle) * 25)
.attr("text-anchor", "middle")
.style("font-size", "10px")
.style("fill", "white")
.style("font-weight", "bold")
.style("opacity", 0)
.text(metric)
.transition()
.delay(500 + i * 50)
.duration(300)
.style("opacity", 1);
});
// Líneas de predicción animadas
const colors = ["#ff6b6b", "#4ecdc4", "#45b7d1"];
radarData.forEach((artist, index) => {
const radarLine = d3.lineRadial()
.angle((d, i) => angleSlice * i)
.radius(d => radarScale(d))
.curve(d3.curveCardinalClosed);
const path = radarSvg.append("path")
.datum(artist.values)
.attr("transform", `translate(${radarCenter.x}, ${radarCenter.y})`)
.style("fill", colors[index])
.style("fill-opacity", 0)
.style("stroke", colors[index])
.style("stroke-width", 3)
.style("stroke-opacity", 0)
.attr("d", radarLine);
// Animar aparición
path.transition()
.delay(800 + index * 200)
.duration(500)
.style("fill-opacity", 0.2)
.style("stroke-opacity", 1);
// Puntos animados
artist.values.forEach((value, i) => {
const angle = angleSlice * i - Math.PI / 2;
const x = radarCenter.x + Math.cos(angle) * radarScale(value);
const y = radarCenter.y + Math.sin(angle) * radarScale(value);
radarSvg.append("circle")
.attr("cx", x)
.attr("cy", y)
.attr("r", 0)
.style("fill", colors[index])
.style("stroke", "#fff")
.style("stroke-width", 2)
.transition()
.delay(1000 + index * 200 + i * 50)
.duration(300)
.attr("r", 4);
});
});
}
// Lado derecho: Rankings con confianza
const rightPanel = mainPanel.append("div");
const rankingContainer = rightPanel.append("div")
.style("background", "rgba(255,255,255,0.1)")
.style("border-radius", "15px")
.style("padding", "20px")
.style("backdrop-filter", "blur(10px)");
rankingContainer.append("h3")
.style("margin", "0 0 20px 0")
.text("🏆 Top 3 Predicciones");
top3Predictions.forEach((prediction, index) => {
const predictionCard = rankingContainer.append("div")
.style("background", "rgba(255,255,255,0.1)")
.style("margin-bottom", "15px")
.style("padding", "15px")
.style("border-radius", "10px")
.style("border-left", `4px solid ${["#ff6b6b", "#4ecdc4", "#45b7d1"][index]}`)
.style("transform", "translateX(-20px)")
.style("opacity", "0");
// Animación de entrada
predictionCard.transition()
.delay(1500 + index * 200)
.duration(500)
.style("transform", "translateX(0px)")
.style("opacity", "1");
const header = predictionCard.append("div")
.style("display", "flex")
.style("justify-content", "space-between")
.style("align-items", "center")
.style("margin-bottom", "10px");
header.append("h4")
.style("margin", "0")
.style("font-size", "16px")
.text(`${index + 1}. ${prediction.name}`);
header.append("div")
.style("background", ["#ff6b6b", "#4ecdc4", "#45b7d1"][index])
.style("color", "white")
.style("padding", "4px 8px")
.style("border-radius", "12px")
.style("font-size", "11px")
.style("font-weight", "bold")
.text(`${prediction.confidence.toFixed(0)}% confianza`);
const metrics = predictionCard.append("div")
.style("font-size", "12px")
.style("line-height", "1.4");
metrics.append("div").text(`🎯 Score: ${prediction.futureStarScore.toFixed(1)}`);
metrics.append("div").text(`📈 Momentum: ${prediction.momentum > 0 ? '↗️' : '↘️'} ${prediction.momentum.toFixed(1)}`);
metrics.append("div").text(`🎵 Obras recientes: ${prediction.recentWorks}`);
metrics.append("div").text(`🤝 Colaboraciones: ${prediction.establishedCollabs}`);
const predictionBadge = predictionCard.append("div")
.style("margin-top", "10px")
.style("text-align", "center")
.style("font-weight", "bold")
.style("color", prediction.prediction === "Muy Probable" ? "#2ecc71" :
prediction.prediction === "Probable" ? "#f39c12" : "#e74c3c")
.text(`Predicción: ${prediction.prediction}`);
});
// Panel de análisis detallado
const analysisPanel = dashboard.append("div")
.style("background", "rgba(255,255,255,0.1)")
.style("border-radius", "15px")
.style("padding", "20px")
.style("backdrop-filter", "blur(10px)")
.style("margin-top", "20px");
analysisPanel.append("h3")
.style("margin", "0 0 20px 0")
.text("🧠 Análisis Predictivo Avanzado");
const analysisGrid = analysisPanel.append("div")
.style("display", "grid")
.style("grid-template-columns", "repeat(auto-fit, minmax(300px, 1fr))")
.style("gap", "20px");
// Factores clave
const factorsCard = analysisGrid.append("div");
factorsCard.append("h4").style("margin", "0 0 10px 0").text("🔑 Factores Clave de Éxito");
const factors = [
{factor: "Especialización en Oceanus Folk", weight: "25%", desc: "Dedicación al género"},
{factor: "Momentum Reciente", weight: "20%", desc: "Tendencia creciente"},
{factor: "Calidad Demostrada", weight: "18%", desc: "Ratio de obras notables"},
{factor: "Red de Colaboraciones", weight: "15%", desc: "Conexiones establecidas"},
{factor: "Influencia Moderna", weight: "12%", desc: "Impacto en nuevos artistas"},
{factor: "Versatilidad", weight: "10%", desc: "Diversidad de géneros"}
];
factors.forEach(f => {
factorsCard.append("div")
.style("margin", "8px 0")
.style("padding", "8px")
.style("background", "rgba(255,255,255,0.1)")
.style("border-radius", "5px")
.style("font-size", "12px")
.html(`<strong>${f.factor}</strong> (${f.weight})<br><em>${f.desc}</em>`);
});
// Metodología
const methodCard = analysisGrid.append("div");
methodCard.append("h4").style("margin", "0 0 10px 0").text("⚙️ Metodología");
methodCard.append("p")
.style("font-size", "13px")
.style("line-height", "1.4")
.style("margin", "0")
.html(`
El modelo predictivo combina <strong>análisis temporal</strong>,
<strong>métricas de red social</strong> y <strong>patrones de calidad</strong>
para identificar artistas con potencial de estrellato.
Se consideran factores como consistencia, colaboraciones estratégicas
y adaptabilidad a tendencias emergentes.
`);
// Iniciar animaciones
createAnimatedRadar();
// Botón de refresh
const refreshButton = dashboard.append("div")
.style("text-align", "center")
.style("margin-top", "20px")
.append("button")
.style("padding", "12px 24px")
.style("border", "none")
.style("border-radius", "25px")
.style("background", "rgba(255,255,255,0.2)")
.style("color", "white")
.style("cursor", "pointer")
.style("font-size", "14px")
.style("backdrop-filter", "blur(10px)")
.text("🔄 Actualizar Predicciones")
.on("click", createAnimatedRadar);
return container;
}
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more