chore: auto-commit before merge (loop primary)

This commit is contained in:
2026-02-16 14:36:25 +00:00
parent 9276955fa8
commit aca57714e4
23 changed files with 1859 additions and 513 deletions
+188
View File
@@ -0,0 +1,188 @@
---
name: d3js-visualization
description: Build deterministic, verifiable data visualizations with D3.js (v6). Generate standalone HTML/SVG (and optional PNG) from local data files without external network dependencies. Use when tasks require charts, plots, axes/scales, legends, tooltips, or data-driven SVG output.
---
# D3.js Visualization Skill
Use this skill to turn structured data (CSV/TSV/JSON) into **clean, reproducible** visualizations using **D3.js**. The goal is to produce **stable outputs** that can be verified by diffing files or hashing.
## When to use
Activate this skill when the user asks for any of the following:
- “Make a chart/plot/graph/visualization”
- bar/line/scatter/area/histogram/box/violin/heatmap
- timelines, small multiples, faceting
- axis ticks, scales, legends, tooltips
- data-driven SVG output for a report or web page
- converting data to a static SVG or HTML visualization
If the user only needs a quick table or summary, **dont** use D3—use a spreadsheet or plain markdown instead.
---
## Inputs you should expect
- One or more local data files: `*.csv`, `*.tsv`, `*.json`
- A chart intent:
- chart type (or you infer the best type)
- x/y fields and aggregation rules
- sorting/filtering rules
- dimensions (width/height) and margins
- color rules (categorical / sequential)
- any labeling requirements (title, axis labels, units)
- Output constraints:
- “static only”, “no animation”, “must be deterministic”, “offline”, etc.
If details are missing, **make reasonable defaults** and document them in comments near the top of the output file.
---
## Outputs you should produce
Prefer producing **all of** the following when feasible:
1. `dist/chart.html` — standalone HTML that renders the visualization
2. `dist/chart.svg` — exported SVG (stable and diff-friendly)
3. (Optional) `dist/chart.png` — if the task explicitly needs a raster image
Always keep outputs in a predictable folder (default: `dist/`), unless the task specifies paths.
---
## Determinism rules (non-negotiable)
To keep results stable across runs and machines:
### Data determinism
- **Sort** input rows deterministically before binding to marks (e.g., by x then by category).
- Use stable grouping order (explicit `Array.from(grouped.keys()).sort()`).
- Avoid locale-dependent formatting unless fixed (use `d3.format`, `d3.timeFormat` with explicit formats).
### Rendering determinism
- **No randomness**: do not use `Math.random()` or `d3-random`.
- **No transitions/animations** by default (transitions can introduce timing variance).
- **Fixed** `width`, `height`, `margin`, `viewBox`.
- Use **explicit tick counts** only when needed; otherwise rely on D3 defaults but keep domains fixed.
- Avoid layout algorithms with non-deterministic iteration unless you control seeds/iterations (e.g., force simulation). If a force layout is required:
- fix the tick count,
- fix initial positions deterministically (e.g., sorted nodes placed on a grid),
- run exactly N ticks and stop.
### Offline + dependency determinism
- Do **not** load D3 from a CDN.
- Pin D3 to a specific version (default: **d3@7.9.0**).
- Prefer vendoring a minified D3 bundle (e.g., `vendor/d3.v7.9.0.min.js`) or bundling with a lockfile.
### File determinism
- Stable SVG output:
- Avoid auto-generated IDs that may change.
- If you must use IDs (clipPath, gradients), derive them from stable strings (e.g., `"clip-plot"`).
- Use LF line endings.
- Keep numeric precision consistent (e.g., round to 24 decimals if needed).
---
## Recommended project layout
If the task doesn't specify an existing structure, use:
```
dist/
chart.html # standalone HTML with inline or linked JS/CSS
chart.svg # exported SVG (optional but nice)
chart.png # rasterized (optional)
vendor/
d3.v7.9.0.min.js # pinned D3 library
```
---
## Interactive features (tooltips, click handlers, hover effects)
When the task requires interactivity (e.g., tooltips on hover, click to highlight):
### Tooltip pattern (recommended)
1. **Create a tooltip element** in HTML:
```html
<div id="tooltip" class="tooltip"></div>
```
2. **Style with CSS** using `.visible` class for show/hide:
```css
.tooltip {
position: absolute;
padding: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
pointer-events: none; /* Prevent mouse interference */
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
}
.tooltip.visible {
opacity: 1; /* Show when .visible class is added */
}
```
3. **Add event handlers** to SVG elements:
```javascript
svg.selectAll('circle')
.on('mouseover', function(event, d) {
d3.select('#tooltip')
.classed('visible', true) // Add .visible class
.html(`<strong>${d.name}</strong><br/>${d.value}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px');
})
.on('mouseout', function() {
d3.select('#tooltip').classed('visible', false); // Remove .visible class
});
```
**Key points:**
- Use `opacity: 0` by default (not `display: none`) for smooth transitions
- Use `.classed('visible', true/false)` to toggle visibility
- `pointer-events: none` prevents tooltip from blocking mouse events
- Position tooltip relative to mouse with `event.pageX/pageY`
### Click handlers for selection/highlighting
```javascript
// Add 'selected' class on click
svg.selectAll('.bar')
.on('click', function(event, d) {
// Remove previous selection
d3.selectAll('.bar').classed('selected', false);
// Add to clicked element
d3.select(this).classed('selected', true);
});
```
CSS for highlighting:
```css
.bar.selected {
stroke: #000;
stroke-width: 3px;
}
```
### Conditional interactivity
Sometimes only certain elements should be interactive:
```javascript
.on('mouseover', function(event, d) {
// Example: Don't show tooltip for certain categories
if (d.category === 'excluded') {
return; // Exit early, no tooltip
}
// Show tooltip for others
showTooltip(event, d);
})
```
---
@@ -0,0 +1,129 @@
/**
* Complete Bubble Chart Example with Force Simulation
*
* This example shows how to create a clustered bubble chart with:
* - Force-directed layout
* - Collision detection
* - Color coding by category
* - Size scaling by value
*/
// Configuration
const width = 800;
const height = 600;
const margin = {top: 20, right: 20, bottom: 20, left: 20};
// Create SVG
const svg = d3.select('#chart')
.append('svg')
.attr('width', width)
.attr('height', height);
// Load and process data
d3.csv('/data/stocks.csv').then(data => {
// Parse numbers
data.forEach(d => {
d.value = d.marketCap ? +d.marketCap : null;
d.ticker = d.ticker;
d.sector = d.sector;
});
createBubbleChart(data);
});
function createBubbleChart(data) {
// Setup scales
const radiusScale = d3.scaleSqrt()
.domain([0, d3.max(data, d => d.value || 0)])
.range([5, 50]);
const colorScale = d3.scaleOrdinal()
.domain([...new Set(data.map(d => d.sector))])
.range(d3.schemeCategory10);
// Calculate radius for each data point
data.forEach(d => {
if (d.value === null || isNaN(d.value)) {
d.radius = 10; // Uniform size for missing data
d.hasValue = false;
} else {
d.radius = radiusScale(d.value);
d.hasValue = true;
}
});
// Create force simulation
const simulation = d3.forceSimulation(data)
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide(d => d.radius + 2))
.force('charge', d3.forceManyBody().strength(-30));
// Optional: Cluster by sector
const sectors = [...new Set(data.map(d => d.sector))];
const sectorPositions = {};
sectors.forEach((sector, i) => {
const angle = (i / sectors.length) * 2 * Math.PI;
sectorPositions[sector] = {
x: width / 2 + Math.cos(angle) * 150,
y: height / 2 + Math.sin(angle) * 150
};
});
simulation
.force('x', d3.forceX(d => sectorPositions[d.sector].x).strength(0.5))
.force('y', d3.forceY(d => sectorPositions[d.sector].y).strength(0.5));
// Create bubbles
const bubbles = svg.selectAll('circle')
.data(data)
.join('circle')
.attr('class', 'bubble')
.attr('r', d => d.radius)
.attr('fill', d => colorScale(d.sector))
.attr('opacity', 0.7)
.on('mouseover', function(event, d) {
// Only show tooltip if has complete data
if (d.sector !== 'ETF') {
showTooltip(event, d);
}
})
.on('mouseout', hideTooltip)
.on('click', function(event, d) {
selectBubble(d.ticker);
});
// Update positions on each tick
simulation.on('tick', () => {
bubbles
.attr('cx', d => d.x)
.attr('cy', d => d.y);
});
}
function showTooltip(event, d) {
const tooltip = d3.select('#tooltip');
tooltip
.classed('visible', true)
.html(`
<strong>${d.ticker}</strong><br/>
${d.name || 'N/A'}<br/>
Sector: ${d.sector}
`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px');
}
function hideTooltip() {
d3.select('#tooltip').classed('visible', false);
}
function selectBubble(ticker) {
// Highlight selected bubble
d3.selectAll('.bubble')
.classed('selected', d => d.ticker === ticker);
// Trigger table highlight if exists
if (typeof highlightTableRow === 'function') {
highlightTableRow(ticker);
}
}
@@ -0,0 +1,64 @@
/**
* Tooltip Implementation Checker
*
* This script helps verify tooltip implementation follows best practices:
* 1. Tooltips use CSS class for visibility
* 2. Tooltips are conditionally displayed
* 3. Tooltip content matches data
*/
// Example: Check if tooltip element exists
function checkTooltipSetup() {
const tooltip = document.getElementById('tooltip');
if (!tooltip) {
console.error('❌ Tooltip element not found');
return false;
}
console.log('✓ Tooltip element exists');
// Check CSS classes
const classes = window.getComputedStyle(tooltip).cssText;
console.log('Tooltip computed styles:', classes);
return true;
}
// Example: Verify tooltip has .visible class mechanism
function checkTooltipVisibility() {
const tooltip = document.getElementById('tooltip');
const hasVisibleClass = tooltip.classList.contains('visible');
console.log('Tooltip has .visible class:', hasVisibleClass);
console.log('Tooltip opacity:', window.getComputedStyle(tooltip).opacity);
return true;
}
// Example: Test tooltip content
function testTooltipContent(sampleData) {
const tooltip = document.getElementById('tooltip');
// Simulate showing tooltip
tooltip.classList.add('visible');
tooltip.innerHTML = `
<strong>${sampleData.ticker}</strong><br/>
${sampleData.name}<br/>
Sector: ${sampleData.sector}
`;
console.log('Tooltip content:', tooltip.innerHTML);
// Clean up
setTimeout(() => {
tooltip.classList.remove('visible');
}, 1000);
}
// Run checks
if (typeof document !== 'undefined') {
console.log('=== Tooltip Implementation Check ===');
checkTooltipSetup();
checkTooltipVisibility();
}
@@ -0,0 +1,186 @@
/**
* Interactive Data Table Example
*
* Creates a sortable, filterable data table that links with charts.
* Features:
* - Two-way highlighting (click table row -> highlight chart element)
* - Formatted numbers
* - Click to sort columns
*/
function createInteractiveTable(data) {
const container = d3.select('#table-container');
// Create table structure
const table = container.append('table')
.attr('id', 'data-table');
// Define columns
const columns = [
{key: 'ticker', label: 'Ticker', format: d => d},
{key: 'name', label: 'Company Name', format: d => d},
{key: 'sector', label: 'Sector', format: d => d},
{key: 'marketCap', label: 'Market Cap', format: formatNumber}
];
// Create header
const thead = table.append('thead');
const headerRow = thead.append('tr');
headerRow.selectAll('th')
.data(columns)
.join('th')
.text(d => d.label)
.on('click', function(event, col) {
sortTable(data, col.key);
})
.style('cursor', 'pointer');
// Create body
const tbody = table.append('tbody');
function renderTable(data) {
const rows = tbody.selectAll('tr')
.data(data, d => d.ticker) // Key function for stable updates
.join('tr')
.on('click', function(event, d) {
selectRow(d.ticker);
});
rows.selectAll('td')
.data(d => columns.map(col => ({
value: d[col.key],
format: col.format
})))
.join('td')
.text(d => d.format(d.value));
}
renderTable(data);
// Sorting function
let sortAscending = true;
let sortKey = null;
function sortTable(data, key) {
if (sortKey === key) {
sortAscending = !sortAscending;
} else {
sortKey = key;
sortAscending = true;
}
data.sort((a, b) => {
const aVal = a[key];
const bVal = b[key];
if (typeof aVal === 'string') {
return sortAscending ?
aVal.localeCompare(bVal) :
bVal.localeCompare(aVal);
} else {
return sortAscending ?
(aVal || 0) - (bVal || 0) :
(bVal || 0) - (aVal || 0);
}
});
renderTable(data);
}
// Return table API
return {
update: renderTable,
sort: sortTable
};
}
function selectRow(ticker) {
// Highlight row
d3.selectAll('#data-table tbody tr')
.classed('selected', function(d) {
return d && d.ticker === ticker;
});
// Trigger chart highlight if exists
if (typeof highlightBubble === 'function') {
highlightBubble(ticker);
}
}
function highlightTableRow(ticker) {
d3.selectAll('#data-table tbody tr')
.classed('highlighted', function(d) {
return d && d.ticker === ticker;
});
}
// Number formatting utility
function formatNumber(num) {
if (num === null || num === undefined || num === '') return '-';
const absNum = Math.abs(num);
let formatted;
if (absNum >= 1e12) {
formatted = (num / 1e12).toFixed(2) + 'T';
} else if (absNum >= 1e9) {
formatted = (num / 1e9).toFixed(2) + 'B';
} else if (absNum >= 1e6) {
formatted = (num / 1e6).toFixed(2) + 'M';
} else if (absNum >= 1e3) {
formatted = (num / 1e3).toFixed(2) + 'K';
} else {
formatted = num.toFixed(2);
}
return num < 0 ? '-' + formatted : formatted;
}
// CSS for table styling
const tableStyles = `
<style>
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th {
background: #f5f5f5;
padding: 10px;
text-align: left;
border-bottom: 2px solid #ddd;
font-weight: bold;
}
th:hover {
background: #e0e0e0;
}
td {
padding: 8px 10px;
border-bottom: 1px solid #eee;
}
tr:hover {
background: #f9f9f9;
}
tr.selected {
background: #e3f2fd !important;
border-left: 3px solid #2196f3;
}
tr.highlighted {
background: #fff9c4 !important;
}
</style>
`;
// Inject styles
if (typeof document !== 'undefined') {
const styleEl = document.createElement('div');
styleEl.innerHTML = tableStyles;
document.head.appendChild(styleEl.firstChild);
}
@@ -0,0 +1,227 @@
/**
* Reusable Tooltip Handler for D3.js Visualizations
*
* Provides a flexible tooltip system with:
* - Conditional display based on data properties
* - Customizable content templates
* - Automatic positioning
* - CSS class-based visibility
*/
class TooltipHandler {
constructor(options = {}) {
this.selector = options.selector || '#tooltip';
this.offsetX = options.offsetX || 10;
this.offsetY = options.offsetY || -10;
this.shouldShow = options.shouldShow || (() => true);
this.formatContent = options.formatContent || this.defaultFormat;
this.tooltip = d3.select(this.selector);
// Create tooltip if it doesn't exist
if (this.tooltip.empty()) {
this.tooltip = d3.select('body')
.append('div')
.attr('id', this.selector.replace('#', ''))
.attr('class', 'tooltip');
}
}
/**
* Show tooltip for a data point
* @param {Event} event - Mouse event
* @param {Object} data - Data point
*/
show(event, data) {
// Check if tooltip should be shown for this data
if (!this.shouldShow(data)) {
return;
}
const content = this.formatContent(data);
this.tooltip
.classed('visible', true)
.html(content)
.style('left', (event.pageX + this.offsetX) + 'px')
.style('top', (event.pageY + this.offsetY) + 'px');
}
/**
* Hide tooltip
*/
hide() {
this.tooltip.classed('visible', false);
}
/**
* Move tooltip to follow mouse
* @param {Event} event - Mouse event
*/
move(event) {
if (this.tooltip.classed('visible')) {
this.tooltip
.style('left', (event.pageX + this.offsetX) + 'px')
.style('top', (event.pageY + this.offsetY) + 'px');
}
}
/**
* Default content formatter
* @param {Object} data - Data point
* @returns {string} HTML content
*/
defaultFormat(data) {
return `
<strong>${data.name || data.id}</strong><br/>
${data.value ? 'Value: ' + data.value : ''}
`;
}
/**
* Attach tooltip handlers to D3 selection
* @param {d3.Selection} selection - D3 selection
*/
attach(selection) {
const self = this;
selection
.on('mouseover', function(event, d) {
self.show(event, d);
})
.on('mousemove', function(event) {
self.move(event);
})
.on('mouseout', function() {
self.hide();
});
return selection;
}
}
// Example usage patterns
/**
* Example 1: Basic tooltip
*/
function example1() {
const tooltip = new TooltipHandler();
d3.selectAll('circle')
.on('mouseover', (event, d) => tooltip.show(event, d))
.on('mouseout', () => tooltip.hide());
}
/**
* Example 2: Conditional tooltip (exclude specific categories)
*/
function example2() {
const tooltip = new TooltipHandler({
shouldShow: (d) => d.category !== 'ETF', // Don't show for ETFs
formatContent: (d) => `
<strong>${d.ticker}</strong><br/>
${d.name}<br/>
Sector: ${d.sector}
`
});
tooltip.attach(d3.selectAll('circle'));
}
/**
* Example 3: Rich formatted tooltip
*/
function example3() {
const tooltip = new TooltipHandler({
shouldShow: (d) => d.hasCompleteData,
formatContent: (d) => {
const parts = [
`<div class="tooltip-header">${d.name}</div>`,
`<div class="tooltip-body">`,
` <div>Ticker: ${d.ticker}</div>`,
` <div>Sector: ${d.sector}</div>`,
];
if (d.marketCap) {
parts.push(` <div>Market Cap: ${formatNumber(d.marketCap)}</div>`);
}
parts.push(`</div>`);
return parts.join('');
}
});
tooltip.attach(d3.selectAll('.bubble'));
}
/**
* Example 4: Multiple tooltips with different styles
*/
function example4() {
// Tooltip for bubbles
const bubbleTooltip = new TooltipHandler({
selector: '#bubble-tooltip',
shouldShow: (d) => d.type !== 'excluded'
});
// Tooltip for table cells
const tableTooltip = new TooltipHandler({
selector: '#table-tooltip',
formatContent: (d) => `Details: ${d.description}`
});
bubbleTooltip.attach(d3.selectAll('.bubble'));
tableTooltip.attach(d3.selectAll('.info-cell'));
}
// Helper function for number formatting
function formatNumber(num) {
if (!num) return '-';
if (num >= 1e12) return (num / 1e12).toFixed(2) + 'T';
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
return num.toFixed(2);
}
// Required CSS (add to your stylesheet)
const requiredCSS = `
.tooltip {
position: absolute;
padding: 10px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
font-size: 14px;
line-height: 1.4;
}
.tooltip.visible {
opacity: 1;
}
.tooltip-header {
font-weight: bold;
margin-bottom: 5px;
border-bottom: 1px solid rgba(255,255,255,0.3);
padding-bottom: 3px;
}
.tooltip-body {
font-size: 12px;
}
.tooltip-body div {
margin: 2px 0;
}
`;
// Export for use in modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = TooltipHandler;
}