Compare commits
7 Commits
b34ecb89e2
...
8178d03cb2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8178d03cb2 | |||
| e9a7581aa5 | |||
| aca57714e4 | |||
| 9276955fa8 | |||
| 8b674ffe14 | |||
| 7d7628c8a7 | |||
| 65b265733e |
Binary file not shown.
@@ -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, **don’t** 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 2–4 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);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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();
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
Binary file not shown.
+38
-19
@@ -1,11 +1,11 @@
|
||||
# Session Handoff
|
||||
|
||||
_Generated: 2026-02-16 12:44:34 UTC_
|
||||
_Generated: 2026-02-16 15:06:20 UTC_
|
||||
|
||||
## Git Context
|
||||
|
||||
- **Branch:** `codex/sidebar`
|
||||
- **HEAD:** 2e242a6: chore: auto-commit before merge (loop primary)
|
||||
- **Branch:** `master`
|
||||
- **HEAD:** e9a7581: chore: auto-commit before merge (loop primary)
|
||||
|
||||
## Tasks
|
||||
|
||||
@@ -30,22 +30,38 @@ _Generated: 2026-02-16 12:44:34 UTC_
|
||||
- [x] Stabilize pathway graph hover/render lifecycle
|
||||
- [x] Unify experience + education card rendering
|
||||
- [x] Aggregate sidebar tags from canonical timeline skills and verify
|
||||
- [x] Constellation data parity: career-only role mapping
|
||||
- [x] Constellation interaction remediation: hover/focus layer
|
||||
- [x] Timeline parity + token alignment
|
||||
- [x] Backpressure and manual review evidence
|
||||
- [x] Resolve build.blocked backpressure gate
|
||||
- [x] Recover build.blocked gate after abandoned retries
|
||||
- [x] Phase 2: Strength-weighted link styling (stroke width, domain color, bezier curves, highlight)
|
||||
- [x] Phase 2: Skill node visual enhancements (stroke, size encoding, glow filter)
|
||||
- [x] Phase 2: Role node visual enhancements (gradient fill, highlight styling)
|
||||
- [x] Phase 2: Entry animation (timeline guides, staggered role/skill/link appearance)
|
||||
- [x] Phase 2: Legend with domain node counts
|
||||
- [x] Data: Include education entities in buildConstellationData
|
||||
- [x] Hook: Create useTimelineAnimation for chronological reveal
|
||||
- [x] Visual: Entry animation reveal effects
|
||||
- [x] Integration: Wire animation to highlight system (Phase 4)
|
||||
- [x] Accessibility: reduced-motion + play/pause button
|
||||
|
||||
|
||||
## Key Files
|
||||
|
||||
Recently modified:
|
||||
|
||||
- `.codex/skills/skills/ralph-setup/SKILL.md`
|
||||
- `.codex/skills/skills/ralph-setup/references/hat-based-reference.md`
|
||||
- `.codex/skills/skills/ralph-setup/references/simple-prompt-reference.md`
|
||||
- `.ralph/agent/handoff.md`
|
||||
- `.ralph/agent/memories.md`
|
||||
- `.ralph/agent/scratchpad.md`
|
||||
- `.ralph/agent/summary.md`
|
||||
- `.ralph/agent/tasks.jsonl`
|
||||
- `.ralph/current-events`
|
||||
- `.ralph/current-loop-id`
|
||||
- `.claude/skills/d3-visualization/SKILL (3).md:Zone.Identifier`
|
||||
- `.claude/skills/d3-visualization/SKILL.md`
|
||||
- `.claude/skills/d3-visualization/scripts/bubble_chart_example.js`
|
||||
- `.claude/skills/d3-visualization/scripts/bubble_chart_example.js:Zone.Identifier`
|
||||
- `.claude/skills/d3-visualization/scripts/check_tooltip.js`
|
||||
- `.claude/skills/d3-visualization/scripts/check_tooltip.js:Zone.Identifier`
|
||||
- `.claude/skills/d3-visualization/scripts/interactive_table_example.js`
|
||||
- `.claude/skills/d3-visualization/scripts/interactive_table_example.js:Zone.Identifier`
|
||||
- `.claude/skills/d3-visualization/scripts/tooltip_handler.js`
|
||||
- `.claude/skills/d3-visualization/scripts/tooltip_handler.js:Zone.Identifier`
|
||||
|
||||
## Next Session
|
||||
|
||||
@@ -54,13 +70,16 @@ Session completed successfully. No pending work.
|
||||
**Original objective:**
|
||||
|
||||
```
|
||||
# Task: Patient Pathway Graph Stability + Unified Experience/Education Data Model
|
||||
# Task: Career Constellation Chart & Layout Polish
|
||||
|
||||
Refactor the patient-pathway style timeline/graph and related experience UI so interaction feels stable, data is consistent across all sections, and education is merged into the same primary timeline flow.
|
||||
Visual polish and layout adjustments to the career constellation chart, sidebar, and repeat medications section. 12 discrete changes across 10 files.
|
||||
|
||||
## Context
|
||||
## Requirements
|
||||
|
||||
Current behavior has two major quality issues:
|
||||
- Hovering graph-related content appears to trigger graph-wide motion/jiggle, implying unnecessary re-rendering or unstable layout state.
|
||||
- Timeline da...
|
||||
### 1. Reduce link opacity (`src/components/constellation/constants.ts`)
|
||||
- Lower `LINK_BASE_OPACITY` from `0.08` → `0.04`
|
||||
- Lower `LINK_STRENGTH_OPACITY_FACTOR` from `0.12` → `0.06`
|
||||
- Makes skill connection lines subtler so job pills are visually clearer
|
||||
|
||||
### 2. White backgro...
|
||||
```
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
[2026-02-16T13:01:00Z] Planning iteration for D3 constellation remediation objective.
|
||||
- Replaced stale prior plan with scoped remediation focused on: (1) role/education selector correctness, (2) pointer interception removal in CareerConstellation accessibility overlay, (3) highlight state coherence across graph/timeline, (4) canonical timeline parity and token alignment.
|
||||
- Key diagnosis: timelineRoleEntities currently aliases full timelineEntities, causing education to leak into role-node graph semantics.
|
||||
- Key diagnosis: absolute keyboard overlay buttons can intercept pointer interactions, likely source of inconsistent desktop hover.
|
||||
- Scope explicitly excludes sidebar/tag work and broad redesign.
|
||||
[2026-02-16T13:14:00Z] Completed atomic task task-1771246519-9ce3 (data parity selectors).
|
||||
- Implemented explicit canonical selectors in `src/data/timeline.ts`: `timelineCareerEntities` and `timelineEducationEntities` from sorted `timelineEntities`.
|
||||
- Re-pointed role-only derivatives to career selector: `timelineConsultations`, `roleSkillMappings`, `roleNodes`, `constellationLinks` now derive from `timelineCareerEntities`.
|
||||
- Kept `timelineRoleEntities` as compatibility alias to `timelineCareerEntities` to avoid downstream breakage while enforcing semantic correctness.
|
||||
- Verification in this iteration: `npm run typecheck` passes.
|
||||
[2026-02-16T13:22:00Z] Recovery iteration routing decision.
|
||||
- Reviewed ready tasks and confirmed no task creation needed: existing open items cover hover/focus remediation, timeline/token parity, and backpressure/manual review evidence.
|
||||
- Selected atomic focus for delegation sequencing: `task-1771246519-1e54` (P1 hover/focus layer) because pointer/focus interception is the highest-risk interaction blocker and unlocks reliable highlight behavior.
|
||||
- Delegation plan: emit `work.start` to Constellation Planner with brief scope cue to produce actionable plan for hover/focus conflict removal and highlight-state stability while preserving touch and keyboard behavior.
|
||||
[2026-02-16T13:34:00Z] Planned atomic execution for task-1771246519-1e54 (hover/focus layer remediation).
|
||||
- Confirmed pointer interception root cause in `src/components/CareerConstellation.tsx`: invisible absolute button overlay uses `pointerEvents: 'auto'` over node hit targets, creating desktop hover dead zones.
|
||||
- Confirmed highlight reset incoherence: mouseleave path emits role highlight null while blur path restores pinned fallback, causing graph/card flicker during transitions.
|
||||
- Confirmed remaining role-detail drift in constellation mobile accordion still sourced from legacy `consultations`; plan now calls for canonical career-entity-derived lookup in this same atomic patch to avoid semantic backslide.
|
||||
- Updated `.ralph/plan.md` with execution-scoped file list, concrete implementation steps, explicit acceptance checks, and out-of-scope guardrail (token alignment deferred to task-1771246519-92f0).
|
||||
[2026-02-16T14:02:00Z] Completed atomic task task-1771246519-1e54 (hover/focus layer remediation).
|
||||
- Removed pointer interception source in `src/components/CareerConstellation.tsx` by setting invisible keyboard overlay buttons to non-pointer-interactive while retaining tab/focus/activation semantics.
|
||||
- Implemented unified fallback resolvers for graph and role highlight state (`resolveGraphFallback`, `resolveRoleFallback`) and used them on graph mouseleave + keyboard blur to eliminate null-reset flicker.
|
||||
- Preserved touch/coarse-pointer tap-to-pin + background clear behavior; skill pin now preserves role highlight fallback instead of always forcing null.
|
||||
- Replaced mobile pinned-role details lookup from legacy `consultations` to canonical `timelineCareerEntities` map to keep constellation role semantics career-only.
|
||||
- Backpressure checks executed: lint pass (2 pre-existing warnings), typecheck pass, build pass.
|
||||
[2026-02-16T14:20:00Z] Coordination iteration focused on task-1771246519-fd59 (Backpressure and manual review evidence).
|
||||
- Re-checked ready queue via `ralph tools task ready`; confirmed two open items remain, with P1 evidence task highest priority.
|
||||
- No new runtime tasks created this iteration to avoid duplication; existing open tasks already map to remaining scope (`fd59` evidence, `92f0` timeline/token parity).
|
||||
- Pending event `build.blocked` requires explicit evidence in next `build.done` payload: tests: pass, lint: pass, typecheck: pass, audit: pass, coverage: pass, complexity: <score>, duplication: pass, performance: pass (optional), specs: pass (optional).
|
||||
- Delegation intent: emit `work.start` to Constellation Planner requesting an execution plan that closes `fd59` by capturing missing backpressure/manual-review evidence in `.ralph/review.md` and preparing compliant `build.done` payload content.
|
||||
|
||||
### HUMAN GUIDANCE (2026-02-16 13:02:25 UTC)
|
||||
|
||||
Just a warning, the chart hover effect only works on the border of the nodes currently, it doesn't work hovering over the fill
|
||||
[2026-02-16T13:03:11Z] Planned atomic execution for task-1771246519-fd59 (Backpressure + manual review evidence).
|
||||
- Read `PROMPT.md`, existing `.ralph/plan.md`, and `.ralph/review.md` to align with pending `work.start` routing and build-block contract.
|
||||
- Added a new `task-1771246519-fd59` section in `.ralph/plan.md` with strict scope boundaries (review/evidence only), explicit required `build.done` field contract, and stepwise instructions for capturing manual interaction verification.
|
||||
- Codified required review notes to include desktop role/skill hover coverage (including node fill hit area), graph↔timeline highlight coherence, touch tap-to-pin/clear behavior, keyboard activation flow, and timeline ordering parity.
|
||||
- Added acceptance criteria requiring explicit statuses for every backpressure field (`tests`, `lint`, `typecheck`, `audit`, `coverage`, `complexity`, `duplication`, optional `performance/specs`) and disallowing missing keys in next `build.done` payload.
|
||||
[2026-02-16T13:04:38Z] Completed atomic task task-1771246519-fd59 (Backpressure + manual review evidence).
|
||||
- Executed required backpressure gates: `npm run lint` (pass with 2 existing warnings), `npm run typecheck` (pass), `npm run build` (pass).
|
||||
- Executed production audit: `npm audit --omit=dev --json` (pass; zero known prod vulnerabilities).
|
||||
- Added dated evidence addendum to `.ralph/review.md` with explicit statuses for every required `build.done` contract field, including `not-configured` rationale for unavailable gates (`tests`, `coverage`, `complexity`, `duplication`, optional `performance/specs`).
|
||||
- Recorded manual interaction verification outcomes in `.ralph/review.md` as carried forward from prior reviewer validation, with explicit note that this iteration made no `src/` feature edits.
|
||||
- Updated `.ralph/plan.md` progress notes with Task D completion and final payload string contract.
|
||||
[2026-02-16T14:30:00Z] Coordination iteration selecting task-1771246519-92f0 (Timeline parity + token alignment).
|
||||
- Verified with `ralph tools task ready` that this is the only unblocked runtime task.
|
||||
- Scope for next atomic execution: align constellation-linked timeline ordering semantics with canonical work-experience ordering and close remaining design-token/font mismatches in chart/timeline-adjacent UI.
|
||||
- Delegation constraints reiterated: preserve completed hover/focus fixes, avoid unrelated redesign, and keep data exports maintainable (career-only vs education-only vs combined consumers).
|
||||
- Build backpressure reminder for completion path: planner/builder must preserve full `build.done` contract fields and ensure lint/typecheck/build remain passing after token/parity edits.
|
||||
- Routing decision: emit `work.start` to Constellation Planner with concise scope cue for task-1771246519-92f0.
|
||||
[2026-02-16T13:07:44Z] Planned atomic execution for task-1771246519-92f0 (timeline parity + token alignment).
|
||||
- Re-validated open task queue with `ralph tools task ready`; task-1771246519-92f0 is the sole unblocked item.
|
||||
- Code inspection confirmed residual parity/token gaps are now concentrated in chronology consumers, not the D3 pointer layer:
|
||||
- `src/components/TimelineInterventionsSubsection.tsx` still uses compatibility `consultations` lookup for detail-panel open path.
|
||||
- `src/components/DashboardLayout.tsx` still derives role click + last-consultation summary from `consultations`/`consultations[0]` rather than explicit canonical career selectors.
|
||||
- Timeline-adjacent components still contain `var(--font-mono)` references while canonical tokens are `--font-geist-mono`/`--font-mono-dashboard`.
|
||||
- `src/components/WorkExperienceSubsection.tsx` remains an unused duplicate path with stale mono token usage; must be normalized or explicitly justified to satisfy legacy-path criterion.
|
||||
- Updated `.ralph/plan.md` with a scoped task-92f0 execution plan: canonical timeline-source alignment, mono-token cleanup, legacy-path handling rule, and regression checks that explicitly preserve previously fixed hover/focus behavior (including fill-area hover reliability).
|
||||
[2026-02-16T13:09:35Z] Completed atomic task task-1771246519-92f0 (timeline parity + token alignment).
|
||||
- Replaced legacy component-level `consultations` dependencies with canonical career timeline source in active chronology paths:
|
||||
- `src/components/TimelineInterventionsSubsection.tsx` now resolves `career-role` panel opens from `timelineConsultations` id map.
|
||||
- `src/components/DashboardLayout.tsx` now derives role click lookup and "Last Consultation" content from `timelineConsultations` (memoized id map + index 0 latest role).
|
||||
- Completed token alignment for remaining timeline-adjacent `var(--font-mono)` usage by switching to canonical `var(--font-geist-mono)` in `TimelineInterventionsSubsection` and legacy `WorkExperienceSubsection`.
|
||||
- Preserved prior hover/focus remediation by avoiding any `CareerConstellation` event-layer changes in this task; interaction checks carried forward in `.ralph/review.md` with explicit no-regression note.
|
||||
- Backpressure checks after edits: `npm run lint` pass (2 existing warnings), `npm run typecheck` pass, `npm run build` pass.
|
||||
[$ts] Coordination iteration for pending build.blocked event.
|
||||
- Ready/open runtime task queues were empty, so a new atomic runtime task was created to track backpressure-gate closure.
|
||||
- Routing choice: emit `work.start` to Constellation Planner to regenerate an execution-ready plan focused on satisfying blocked backpressure requirements and producing a compliant `build.done` contract payload.
|
||||
- Scope guard: no direct implementation in this coordinator iteration; delegation only.
|
||||
[2026-02-16T13:11:00Z] Coordination correction.
|
||||
- Reasserting delegation-only routing for pending build.blocked: publish work.start to Constellation Planner for backpressure closure plan and compliant build.done payload fields.
|
||||
[2026-02-16T13:12:00Z] Planned atomic execution for task-1771247453-c78f (resolve build.blocked backpressure gate).
|
||||
- Confirmed runtime queue has one open P1 task: `Resolve build.blocked backpressure gate` with description requiring planner/builder coordination and compliant `build.done` payload fields.
|
||||
- Updated `.ralph/plan.md` with a new atomic section scoped to evidence + event closure only (no `src/` edits unless a gate fails), including exact required key contract for `build.done`.
|
||||
- Plan requires builder to re-run lint/typecheck/build/audit, explicitly mark unconfigured gates (`tests`, `coverage`, `complexity`, `duplication`, optional `performance/specs`), update `.ralph/review.md` with dated evidence, and emit one full-field `build.done` payload.
|
||||
[2026-02-16T13:13:03Z] Completed atomic task task-1771247453-c78f (resolve build.blocked backpressure gate).
|
||||
- Re-read `PROMPT.md` and `.ralph/plan.md` scope, then re-ran closure gates in current workspace: `npm run lint` (pass with 2 existing warnings), `npm run typecheck` (pass), `npm run build` (pass), `npm audit --omit=dev --json` (pass; zero prod vulnerabilities).
|
||||
- Re-validated backpressure contract availability from `package.json`: tests/coverage/complexity/duplication/performance/specs are not configured and must be emitted explicitly as `not-configured`.
|
||||
- Appended fresh dated closure evidence to `.ralph/review.md` and appended completion note to `.ralph/plan.md` for task-c78f.
|
||||
- Next step in this iteration: close runtime task and emit one compliant `build.done` payload with full required field set.
|
||||
[2026-02-16T14:33:00Z] Coordination iteration for pending build.task.abandoned + build.blocked recovery.
|
||||
- Ran `ralph tools task ready` and confirmed no ready runtime tasks remained; created atomic P1 task `task-1771247613-8412` to track gate-recovery coordination.
|
||||
- Objective status indicates implementation remediation is already complete; current blocker is orchestration backpressure signaling after repeated blocked events.
|
||||
- Scope this iteration is delegation-only: publish a single `work.start` event to Constellation Planner requesting a closure plan that yields a compliant `build.done` payload with all required fields explicitly present (pass or not-configured rationale).
|
||||
- Guardrails: no `src/` edits in this iteration; stop immediately after event publish.
|
||||
@@ -1,8 +1,8 @@
|
||||
# Loop Summary
|
||||
|
||||
**Status:** Completed successfully
|
||||
**Iterations:** 10
|
||||
**Duration:** 19m 11s
|
||||
**Iterations:** 4
|
||||
**Duration:** 6m 33s
|
||||
|
||||
## Tasks
|
||||
|
||||
@@ -14,4 +14,4 @@ _No events recorded._
|
||||
|
||||
## Final Commit
|
||||
|
||||
6832754: Removed top bar, and updating sidebar
|
||||
aca5771: chore: auto-commit before merge (loop primary)
|
||||
|
||||
@@ -22,4 +22,14 @@
|
||||
{"id":"task-1771246519-92f0","title":"Timeline parity + token alignment","description":"Align DashboardLayout/TimelineInterventionsSubsection role mapping with canonical timeline semantics and replace invalid mono token usages in constellation/timeline-adjacent components.","status":"closed","priority":2,"blocked_by":[],"loop_id":"primary-20260216-125331","created":"2026-02-16T12:55:19.496369652+00:00","closed":"2026-02-16T13:10:13.908306807+00:00"}
|
||||
{"id":"task-1771246519-fd59","title":"Backpressure and manual review evidence","description":"Run lint/typecheck/build and capture required manual behavioral checks in .ralph/review.md.","status":"closed","priority":1,"blocked_by":[],"loop_id":"primary-20260216-125331","created":"2026-02-16T12:55:19.589153691+00:00","closed":"2026-02-16T13:05:11.472526635+00:00"}
|
||||
{"id":"task-1771247453-c78f","title":"Resolve build.blocked backpressure gate","description":"Coordinate planner/builder pass to satisfy backpressure contract and emit compliant build.done payload fields.","status":"closed","priority":1,"blocked_by":[],"loop_id":"primary-20260216-125331","created":"2026-02-16T13:10:53.575377010+00:00","closed":"2026-02-16T13:13:10.138432665+00:00"}
|
||||
{"id":"task-1771247613-8412","title":"Recover build.blocked gate after abandoned retries","description":"Coordinate recovery for build.task.abandoned/build.blocked by delegating to Constellation Planner for a compliant backpressure closure plan and full build.done field contract.","status":"open","priority":1,"blocked_by":[],"loop_id":"primary-20260216-125331","created":"2026-02-16T13:13:33.623635920+00:00"}
|
||||
{"id":"task-1771247613-8412","title":"Recover build.blocked gate after abandoned retries","description":"Coordinate recovery for build.task.abandoned/build.blocked by delegating to Constellation Planner for a compliant backpressure closure plan and full build.done field contract.","status":"closed","priority":1,"blocked_by":[],"loop_id":"primary-20260216-125331","created":"2026-02-16T13:13:33.623635920+00:00","closed":"2026-02-16T14:00:46.735863152+00:00"}
|
||||
{"id":"task-1771250865-84b6","title":"Phase 2: Strength-weighted link styling (stroke width, domain color, bezier curves, highlight)","description":"Links: width 0.5+strength*1.5, domain-colored opacity 0.08+strength*0.12, bezier offset by vertical distance, highlight width 1+strength*2","status":"closed","priority":2,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:07:45.033975574+00:00","closed":"2026-02-16T14:16:43.367692378+00:00"}
|
||||
{"id":"task-1771250867-3847","title":"Phase 2: Skill node visual enhancements (stroke, size encoding, glow filter)","description":"Skill nodes: domain-colored stroke at rest (width 1, opacity 0.4), size by connected role count (base+roleCount*0.8), glow filter on highlight (feGaussianBlur 2-3px)","status":"closed","priority":2,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:07:47.014409046+00:00","closed":"2026-02-16T14:16:43.466730457+00:00"}
|
||||
{"id":"task-1771250867-53ab","title":"Phase 2: Role node visual enhancements (gradient fill, highlight styling)","description":"Role nodes: fill gradient left-right orgColor@0.08 to @0.18, highlight fill-opacity 0.25 stroke-width 2 shadow-md filter","status":"closed","priority":2,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:07:47.938924710+00:00","closed":"2026-02-16T14:16:43.560201060+00:00"}
|
||||
{"id":"task-1771250870-ef11","title":"Phase 2: Entry animation (timeline guides, staggered role/skill/link appearance)","description":"Timeline guides fade 200ms, role nodes slide left staggered 80ms/300ms, skill nodes scale from 0 staggered 30ms/250ms, links stroke-dashoffset, skip if prefers-reduced-motion","status":"closed","priority":2,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:07:50.192275123+00:00","closed":"2026-02-16T14:16:43.655688295+00:00"}
|
||||
{"id":"task-1771250871-282f","title":"Phase 2: Legend with domain node counts","description":"Domain node counts in legend: Technical (8) · Clinical (6) · Leadership (7)","status":"closed","priority":3,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:07:51.272435950+00:00","closed":"2026-02-16T14:16:43.750288318+00:00"}
|
||||
{"id":"task-1771251473-edda","title":"Data: Include education entities in buildConstellationData","description":"Modify buildConstellationData() in timeline.ts to include education entities (A-Levels, MPharm). Update types if needed. Timeline order: A-Levels(2009) → MPharm(2011) → Pre-Reg(2015) → Duty Manager(2016) → Pharmacy Manager(2017) → High Cost Drugs(2022) → Deputy Head(2024) → Interim Head(2025)","status":"closed","priority":1,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:17:53.257500198+00:00","closed":"2026-02-16T14:31:18.540275097+00:00"}
|
||||
{"id":"task-1771251475-c04e","title":"Hook: Create useTimelineAnimation for chronological reveal","description":"All nodes hidden initially (opacity:0), reveal chronologically per role/education entity. rAF+timestamp scheduler, state machine in refs (IDLE→PLAYING→PAUSED→HOLDING→RESETTING→PLAYING). Auto-play after sim settles. Loop: hold 3s → fade 400ms → pause 200ms → restart. Skills already visible get reinforcement pulse (1.3x→1.0x 350ms). Year indicator overlay.","status":"closed","priority":1,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:17:55.507983567+00:00","closed":"2026-02-16T14:31:18.642276867+00:00"}
|
||||
{"id":"task-1771251477-81a2","title":"Visual: Entry animation reveal effects","description":"Role nodes scale from 0 with ease-out-back. New skill nodes scale from 0 with ease-out. Links draw on via stroke-dashoffset. Year indicator top-left monospace. Replace Phase 2 entry animation with timeline-driven animation.","status":"closed","priority":2,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:17:57.098724221+00:00","closed":"2026-02-16T14:31:18.740781337+00:00"}
|
||||
{"id":"task-1771251479-1473","title":"Integration: Wire animation to highlight system (Phase 4)","description":"Multiplicative opacity: animation(0/target) × highlight(1.0/0.15). Hover/tap pauses animation. Highlight only on revealed nodes. Resume 800ms after interaction ends. Explicit pause via button stays paused. Play/pause toggle bottom-right. Mobile accordion + keyboard nav + detail panel work during pause. prefers-reduced-motion shows final state.","status":"closed","priority":2,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:17:59.857204916+00:00","closed":"2026-02-16T14:31:18.838001047+00:00"}
|
||||
{"id":"task-1771251482-f0e9","title":"Accessibility: reduced-motion + play/pause button","description":"prefers-reduced-motion skips animation entirely, shows final state. Play/pause button with aria-label, subtle styling, larger touch target on mobile.","status":"closed","priority":2,"blocked_by":[],"loop_id":"primary-20260216-135722","created":"2026-02-16T14:18:02.061675075+00:00","closed":"2026-02-16T14:31:18.930889962+00:00"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
.ralph/events-20260216-125331.jsonl
|
||||
.ralph/events-20260216-145940.jsonl
|
||||
@@ -1 +1 @@
|
||||
primary-20260216-125331
|
||||
primary-20260216-145940
|
||||
@@ -0,0 +1 @@
|
||||
{"ts":"2026-02-16T13:57:03.068443207+00:00","iteration":0,"hat":"loop","topic":"work.start","triggered":"planner","payload":"# Task: CareerConstellation Overhaul\n\nRefactor, visually improve, and add chronological animation to the CareerConstellation D3 force chart — the centrepiece of the portfolio's Patient Pathway section.\n\n## Requirements\n\n### Phase 1 — Refactor the Monolith\n\nDecompose `src/components/CareerConstellation.tsx` (1102 lines) into focused modules:\n\n```\nsrc/components/constellation/\n CareerConstellation.tsx -- Orchestrator (< 300 lines)\n MobileAccordion.tsx -- Mobile tap-to-e... [truncated, 8244 chars total]"}
|
||||
@@ -0,0 +1,13 @@
|
||||
{"ts":"2026-02-16T13:57:22.843138073+00:00","iteration":0,"hat":"loop","topic":"work.start","triggered":"planner","payload":"# Task: CareerConstellation Overhaul\n\nRefactor, visually improve, and add chronological animation to the CareerConstellation D3 force chart — the centrepiece of the portfolio's Patient Pathway section.\n\n## Requirements\n\n### Phase 1 — Refactor the Monolith\n\nDecompose `src/components/CareerConstellation.tsx` (1102 lines) into focused modules:\n\n```\nsrc/components/constellation/\n CareerConstellation.tsx -- Orchestrator (< 300 lines)\n MobileAccordion.tsx -- Mobile tap-to-e... [truncated, 8244 chars total]"}
|
||||
{"payload":"Phase 1: Refactor monolith into 9 files (constants, types, 3 hooks, 3 components, orchestrator). Detailed signatures and build order in .ralph/plan.md","topic":"plan.ready","ts":"2026-02-16T14:00:52.562250965+00:00"}
|
||||
{"payload":"Phase 1 complete: monolith decomposed into 9 files. Orchestrator 288 lines. lint/typecheck/build pass. Commit 65b2657.","topic":"build.done","ts":"2026-02-16T14:07:01.499647791+00:00"}
|
||||
{"payload":"Phase 2 Visual Improvements: 5 tasks created for link styling, skill nodes, role nodes, entry animation, and legend counts. Planner should analyse the decomposed codebase (src/components/constellation/ and src/hooks/) and produce detailed implementation plan.","topic":"work.start","ts":"2026-02-16T14:08:10.797746949+00:00"}
|
||||
{"payload":"Phase 2 Visual Improvements plan written to .ralph/plan.md. 5 tasks: link styling (strength-weighted, domain-colored, bezier), skill nodes (stroke, size encoding, glow), role nodes (gradient fill, highlight), entry animation (stagger sequence), legend counts. Build order: constants first, then links, skills, roles, legend, animation.","topic":"plan.ready","ts":"2026-02-16T14:12:03.119959409+00:00"}
|
||||
{"payload":"Phase 2 complete: 5 tasks (links, skills, roles, entry animation, legend). lint 0 errors, typecheck clean, build success. Commit 7d7628c. Orchestrator 294 lines.","topic":"build.done","ts":"2026-02-16T14:17:06.835122520+00:00"}
|
||||
{"payload":"Phase 3+4: Over-Time Animation + Interaction Integration. lint: pass, typecheck: pass, build: pass. 5 tasks created. Planner should analyze current codebase (especially useForceSimulation.ts entry animation, timeline.ts education entities, useConstellationHighlight.ts) and produce detailed implementation plan for chronological animation with multiplicative opacity model.","topic":"work.start","ts":"2026-02-16T14:18:24.491507486+00:00"}
|
||||
{"payload":"Phase 3+4 plan written to .ralph/plan.md. 5 tasks: data (education entities), hook (useTimelineAnimation with rAF scheduler + state machine), visual (remove entry animation, add reveal effects + year indicator), integration (multiplicative opacity + interaction wiring), a11y (reduced-motion + play/pause button). Build order: data → hook → visual → integration → a11y.","topic":"plan.ready","ts":"2026-02-16T14:21:47.204426315+00:00"}
|
||||
{"payload":"Phase 3+4 complete: education entities, useTimelineAnimation hook, chronological reveal, multiplicative opacity, interaction wiring, play/pause button, reduced-motion support. lint: 0 errors, typecheck: clean, build: 4.51s. Commit 8b674ff.","topic":"build.done","ts":"2026-02-16T14:32:07.328158738+00:00"}
|
||||
{"payload":"334 is fine","topic":"human.guidance","ts":"2026-02-16T14:34:30.279287876+00:00"}
|
||||
{"payload":"All quality gates pass. Orchestrator at 285 lines. All success criteria met.","topic":"build.done","ts":"2026-02-16T14:35:20.154349304+00:00"}
|
||||
{"payload":"All phases complete. lint: 0 errors, typecheck: clean, build: pass (4.57s). Orchestrator 285 lines. All tasks closed.","topic":"LOOP_COMPLETE","ts":"2026-02-16T14:36:18.771050831+00:00"}
|
||||
{"ts":"2026-02-16T14:36:25.800795816+00:00","iteration":10,"hat":"loop","topic":"loop.terminate","payload":"## Reason\ncompleted\n\n## Status\nAll tasks completed successfully.\n\n## Summary\n- Iterations: 10\n- Duration: 39m 2s\n- Exit code: 0"}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"ts":"2026-02-16T14:59:40.739368380+00:00","iteration":0,"hat":"loop","topic":"task.start","triggered":"planner","payload":"# Task: Career Constellation Chart & Layout Polish\n\nVisual polish and layout adjustments to the career constellation chart, sidebar, and repeat medications section. 12 discrete changes across 10 files.\n\n## Requirements\n\n### 1. Reduce link opacity (`src/components/constellation/constants.ts`)\n- Lower `LINK_BASE_OPACITY` from `0.08` → `0.04`\n- Lower `LINK_STRENGTH_OPACITY_FACTOR` from `0.12` → `0.06`\n- Makes skill connection lines subtler so job pills are visually clearer\n\n### 2. White backgro... [truncated, 7323 chars total]"}
|
||||
{"payload":"All 12 items verified as already implemented. Lint 0 errors, typecheck clean, build passes.","topic":"LOOP_COMPLETE","ts":"2026-02-16T15:06:16.343467867+00:00"}
|
||||
{"ts":"2026-02-16T15:06:20.507836443+00:00","iteration":4,"hat":"loop","topic":"loop.terminate","payload":"## Reason\ncompleted\n\n## Status\nAll tasks completed successfully.\n\n## Summary\n- Iterations: 4\n- Duration: 6m 33s\n- Exit code: 0"}
|
||||
File diff suppressed because one or more lines are too long
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"pid": 1000871,
|
||||
"started": "2026-02-16T12:53:31.960971126Z",
|
||||
"prompt": "# Task: D3 Career Constellation Remediation (Hover, Timeline Parity, Visual Alignment)\n\nImplement a..."
|
||||
"pid": 1100162,
|
||||
"started": "2026-02-16T14:59:40.714777647Z",
|
||||
"prompt": "# Task: Career Constellation Chart & Layout Polish\n\nVisual polish and layout adjustments to the car..."
|
||||
}
|
||||
+482
-314
@@ -1,367 +1,535 @@
|
||||
# D3 Constellation Remediation Plan (Hover, Timeline Parity, Token Alignment)
|
||||
# Phase 3+4 Plan — Over-Time Animation + Interaction Integration
|
||||
|
||||
## Objective
|
||||
Restore reliable constellation interactions and align timeline semantics/styling with the dashboard system without broad refactors.
|
||||
## Goal
|
||||
Build the constellation chronologically from 2009 to present, replacing the Phase 2 entry animation with a looping timeline reveal. Wire animation to the existing highlight system using multiplicative opacity. Add play/pause control and reduced-motion support.
|
||||
|
||||
## Current Findings (from code inspection)
|
||||
- Pointer/focus layer conflict: `src/components/CareerConstellation.tsx` renders an absolute full-chart button overlay with `pointerEvents: 'auto'` per node. This can intercept pointer hover intended for SVG node groups, making desktop highlight activation inconsistent.
|
||||
- Timeline semantic drift: `src/data/timeline.ts` currently exports `timelineRoleEntities = timelineEntities`, so education items are incorrectly treated as role nodes for constellation data generation.
|
||||
- Timeline/card data coupling still uses compatibility layer in key UI paths:
|
||||
- `src/components/CareerConstellation.tsx` reads pinned accordion content from `consultations`.
|
||||
- `src/components/TimelineInterventionsSubsection.tsx` uses `consultationsById` for detail panel open.
|
||||
- `src/components/DashboardLayout.tsx` uses `consultations` for role click and “Last Consultation”.
|
||||
- Highlight state split remains (`highlightedNodeId` vs `highlightedRoleId` in `DashboardLayout`), increasing mismatch risk between graph and timeline cards.
|
||||
- Font token mismatch persists: components use `var(--font-mono)` while tokens define `--font-geist-mono` / `--font-mono-dashboard` in `src/index.css`.
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
- In scope:
|
||||
- Constellation pointer/focus/hover reliability and highlight lifecycle.
|
||||
- Timeline role/education semantic parity between graph and chronology stream.
|
||||
- Token-consistent typography fixes in constellation and timeline-adjacent components.
|
||||
- Cleanup of duplicate timeline consumer paths only where they cause behavioral divergence.
|
||||
- Out of scope:
|
||||
- Sidebar/tag system changes.
|
||||
- New visual redesigns unrelated to existing card/token language.
|
||||
- Non-pathway feature work.
|
||||
## Task Order
|
||||
|
||||
## File-Level Implementation Steps
|
||||
1. Fix role vs education selectors in canonical timeline exports.
|
||||
- File: `src/data/timeline.ts`
|
||||
- Changes:
|
||||
- Export explicit selectors:
|
||||
- `timelineCareerEntities` (`kind === 'career'`)
|
||||
- `timelineEducationEntities` (`kind === 'education'`)
|
||||
- keep `timelineEntities` as combined sorted list.
|
||||
- Build constellation role nodes, mappings, and links from `timelineCareerEntities` only.
|
||||
- Keep compatibility exports only if required by current panel types; avoid role graph deriving from combined data.
|
||||
- Acceptance:
|
||||
- No education entry appears as `type: 'role'` in `buildConstellationData()` outputs.
|
||||
Five tasks, built in dependency order. Tasks 1-2 are P1 (foundations), 3-5 are P2 (visual/integration/a11y).
|
||||
|
||||
2. Remove pointer interception while preserving keyboard accessibility.
|
||||
- File: `src/components/CareerConstellation.tsx`
|
||||
- Changes:
|
||||
- Replace always-active absolute button hit targets with focus-only accessibility controls that do not capture pointer hover.
|
||||
- Maintain keyboard tab/focus/Enter/Space activation behavior.
|
||||
- Keep touch coarse-pointer tap-to-pin + background clear behavior.
|
||||
- Ensure mouseenter/mouseleave on D3 nodes are the authoritative desktop hover path.
|
||||
- Acceptance:
|
||||
- Desktop pointer hover over visible SVG nodes consistently activates highlight.
|
||||
- Keyboard focus still highlights and activates nodes.
|
||||
---
|
||||
|
||||
3. Stabilize highlight source-of-truth and reset semantics.
|
||||
- Files: `src/components/CareerConstellation.tsx`, `src/components/DashboardLayout.tsx`, `src/components/TimelineInterventionsSubsection.tsx`
|
||||
- Changes:
|
||||
- Normalize graph/card highlight flow so role hover, skill hover, and card hover transitions do not flicker on mouseleave/blur.
|
||||
- Ensure blur/mouseleave fall back to current pinned/external highlight state coherently (no forced null unless intended).
|
||||
- Keep role-card cross-highlight and avoid skill-hover clearing active role card unexpectedly.
|
||||
- Acceptance:
|
||||
- Highlight transitions are predictable when moving pointer between graph nodes and timeline cards.
|
||||
- No visible reset/flicker on quick node-to-node movement.
|
||||
### Task 1: Data — Include education entities (task-1771251473-edda)
|
||||
|
||||
4. Align timeline/detail consumers to canonical timeline semantics.
|
||||
- Files: `src/components/CareerConstellation.tsx`, `src/components/TimelineInterventionsSubsection.tsx`, `src/components/DashboardLayout.tsx`, optional `src/types/pmr.ts`
|
||||
- Changes:
|
||||
- Prefer timeline-entity-based lookup for role details where feasible, with career-only lookup for constellation role interactions.
|
||||
- Keep education entries in chronology stream, but exclude from role-node click/hover mapping.
|
||||
- Verify timeline ordering matches work-experience chronology intent (latest to oldest parity).
|
||||
- Acceptance:
|
||||
- Constellation role interactions map to career records only.
|
||||
- Chronology order in timeline stream matches expected work-experience-first semantics.
|
||||
**Files:** `src/data/timeline.ts`, `src/types/pmr.ts`
|
||||
|
||||
5. Token-consistent typography cleanup (no redesign).
|
||||
- Files: `src/components/CareerConstellation.tsx`, `src/components/TimelineInterventionsSubsection.tsx`, `src/components/DashboardLayout.tsx`, `src/index.css`
|
||||
- Changes:
|
||||
- Replace invalid `var(--font-mono)` usage with canonical mono token (`var(--font-geist-mono)` or standardized dashboard mono alias).
|
||||
- Keep UI text on existing UI token family (`var(--font-ui)` where already used).
|
||||
- Acceptance:
|
||||
- No unresolved/undefined font token usage remains in constellation/timeline-adjacent UI.
|
||||
**`src/types/pmr.ts` changes:**
|
||||
|
||||
6. Verification and review notes.
|
||||
- Commands:
|
||||
- `npm run lint`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- Manual checks to record in `.ralph/review.md`:
|
||||
- Desktop hover on role and skill nodes.
|
||||
- Graph ↔ timeline cross-highlight behavior.
|
||||
- Touch/coarse-pointer tap-to-pin and clear.
|
||||
- Keyboard focus navigation and activation.
|
||||
- Timeline order parity sanity check vs work-experience content.
|
||||
1. **ConstellationNode.type** — Add `'education'` as a valid type:
|
||||
```ts
|
||||
type: 'role' | 'skill' | 'education'
|
||||
```
|
||||
This allows education nodes to have distinct styling (e.g., dashed border, different shape) while sharing role-like positioning on the timeline.
|
||||
|
||||
## Suggested Runtime Task Sequence
|
||||
- Task A: Data parity selectors + constellation career-only mapping.
|
||||
- Task B: Constellation pointer/focus layer remediation + highlight state stabilization.
|
||||
- Task C: Timeline/detail consumer parity + token alignment.
|
||||
- Task D: Backpressure checks + manual verification notes in `.ralph/review.md`.
|
||||
**`src/data/timeline.ts` changes:**
|
||||
|
||||
## Completion Gate
|
||||
All objective success criteria pass, including lint/typecheck/build and recorded manual verification outcomes.
|
||||
2. **`buildConstellationData()`** — Include education entities alongside career entities:
|
||||
- Change `timelineCareerEntities` → `timelineEntities` (all entities) in `roleSkillMappings`, `roleNodes`, and `constellationLinks` builders
|
||||
- For education entities, use `type: 'education'` instead of `type: 'role'`
|
||||
- Education entities already have `skills`, `skillStrengths`, `orgColor`, `graphLabel`, and `dateRange` — no data changes needed
|
||||
- The `roleNodes` builder becomes `entityNodes` conceptually but keep the variable name for minimal diff
|
||||
|
||||
## Runtime Task IDs
|
||||
- `task-1771246519-9ce3` Constellation data parity: career-only role mapping
|
||||
- `task-1771246519-1e54` Constellation interaction remediation: hover/focus layer
|
||||
- `task-1771246519-92f0` Timeline parity + token alignment
|
||||
- `task-1771246519-fd59` Backpressure and manual review evidence
|
||||
Specific changes to `buildConstellationData()`:
|
||||
```ts
|
||||
// Line 450: Change timelineCareerEntities → timelineEntities
|
||||
const roleSkillMappings = timelineEntities.map(entity => ({
|
||||
roleId: entity.id,
|
||||
skillIds: entity.skills,
|
||||
}))
|
||||
|
||||
## Progress Notes
|
||||
- 2026-02-16: Completed Task A (`task-1771246519-9ce3`).
|
||||
- Added explicit timeline selectors in `src/data/timeline.ts`:
|
||||
- `timelineCareerEntities` (`kind === 'career'`)
|
||||
- `timelineEducationEntities` (`kind === 'education'`)
|
||||
- compatibility alias `timelineRoleEntities = timelineCareerEntities`
|
||||
- Updated constellation role nodes/mappings/links and `timelineConsultations` derivation to use `timelineCareerEntities` only.
|
||||
- Validation: `npm run typecheck` passed.
|
||||
// Line 455: Change timelineCareerEntities → timelineEntities, add education type
|
||||
const roleNodes = timelineEntities.map(entity => ({
|
||||
id: entity.id,
|
||||
type: entity.kind === 'education' ? 'education' as const : 'role' as const,
|
||||
label: entity.title,
|
||||
shortLabel: entity.graphLabel,
|
||||
organization: entity.organization,
|
||||
startYear: entity.dateRange.startYear,
|
||||
endYear: entity.dateRange.endYear,
|
||||
orgColor: entity.orgColor,
|
||||
}))
|
||||
|
||||
## Atomic Execution Plan: task-1771246519-1e54 (Hover/Focus Layer)
|
||||
// Line 474: Change timelineCareerEntities → timelineEntities
|
||||
const constellationLinks = timelineEntities.flatMap(entity => ...)
|
||||
```
|
||||
|
||||
### Scope for this execution
|
||||
- Primary files: `src/components/CareerConstellation.tsx`, `src/components/DashboardLayout.tsx`, `src/components/TimelineInterventionsSubsection.tsx`
|
||||
- Allowed supporting touchpoint: `src/data/timeline.ts` only if career-entity lookup is needed to replace role detail dependencies in constellation overlay content.
|
||||
- Explicitly out of scope for this task: typography token cleanup and broader timeline consumer consolidation (covered by `task-1771246519-92f0`).
|
||||
**Impact on downstream:**
|
||||
- `constellationNodes` now includes 2 education nodes (A-Levels, MPharm)
|
||||
- `constellationLinks` now includes links from education entities to skills
|
||||
- `roleSkillMappings` now includes education entity mappings
|
||||
- `useForceSimulation.ts` filters `roleNodes` at line 35 with `.filter(n => n.type === 'role')` — this needs updating to include `'education'` type for timeline placement: `.filter(n => n.type === 'role' || n.type === 'education')`
|
||||
- The orchestrator's `buildScreenReaderDescription()` and `careerEntityById` already use `constellationNodes` and `timelineCareerEntities` respectively — the description function should handle education nodes, and the entity lookup should extend to all timeline entities
|
||||
- The `nodeById` lookup in `useForceSimulation.ts` (line 277) uses `constellationNodes` directly — no change needed
|
||||
|
||||
### Diagnosed root causes to remediate
|
||||
- Pointer interception:
|
||||
- `CareerConstellation` accessibility layer buttons are absolute-positioned, full-hitbox, and `pointerEvents: 'auto'` while parent group is `pointerEvents: 'none'`.
|
||||
- These controls overlap node hit targets and can steal/mask pointer hover intended for D3 `g.node` handlers.
|
||||
- Highlight fallback inconsistency:
|
||||
- Graph mouseleave unconditionally calls `onNodeHover(null)` while blur path restores `onNodeHover(pinnedNodeId)`.
|
||||
- This mixed reset policy causes card highlight flicker when moving between graph nodes, cards, and focus controls.
|
||||
- Role detail lookup drift:
|
||||
- Mobile pinned accordion currently resolves role details from legacy `consultations`, not canonical timeline career entities.
|
||||
**Education node visual styling (in useForceSimulation.ts):**
|
||||
- Education nodes should render like role nodes but with a dashed border to visually distinguish them
|
||||
- Same `rw`/`rh` dimensions, same gradient fill, but `stroke-dasharray: '4 3'`
|
||||
- Change role-specific rendering filters to include education: `.filter(d => d.type === 'role' || d.type === 'education')`
|
||||
|
||||
### Implementation steps for builder
|
||||
1. Make keyboard overlay non-intercepting for pointer.
|
||||
- File: `src/components/CareerConstellation.tsx`
|
||||
- Replace always-active button layer with a focus-only model:
|
||||
- Keep semantic `button` controls for tab/Enter/Space.
|
||||
- Prevent pointer capture by default (`pointerEvents: 'none'` on buttons), and only enable during keyboard focus state when needed.
|
||||
- Preserve visible focus ring via existing `.focus-ring` sync (`focusedNodeId` path).
|
||||
- Ensure keyboard users can still tab through all nodes in deterministic order.
|
||||
**Pitfall:** The `roleNodes` constant at line 35 of `useForceSimulation.ts` is module-level, computed once. After adding education entities, it must include education nodes for year scale computation. Update to: `const roleNodes = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')`
|
||||
|
||||
2. Unify highlight fallback semantics across mouse and keyboard.
|
||||
- Files: `src/components/CareerConstellation.tsx`, `src/components/DashboardLayout.tsx`, `src/components/TimelineInterventionsSubsection.tsx`
|
||||
- Introduce one fallback resolver in constellation:
|
||||
- `resolveFallbackHighlight = highlightedNodeIdRef.current ?? pinnedNodeIdRef.current`
|
||||
- Use this on node mouseleave and accessibility-control blur (instead of mixed null/pinned behavior).
|
||||
- Keep skill hover from driving role-card highlight:
|
||||
- Role hover/focus sets role highlight.
|
||||
- Skill hover/focus should not forcibly clear an active role highlight unless fallback is null.
|
||||
- Ensure timeline card mouseleave does not induce graph/card thrash when crossing between adjacent cards.
|
||||
---
|
||||
|
||||
3. Preserve touch behavior while removing desktop hover conflict.
|
||||
- File: `src/components/CareerConstellation.tsx`
|
||||
- Keep existing coarse-pointer behavior:
|
||||
- Node tap toggles pin.
|
||||
- Background tap clears pin + highlight.
|
||||
- Confirm touch branch remains independent from desktop hover path after overlay change.
|
||||
### Task 2: Hook — Create useTimelineAnimation (task-1771251475-c04e)
|
||||
|
||||
4. Align mobile pinned role details with canonical timeline career data.
|
||||
- File: `src/components/CareerConstellation.tsx` (and `src/data/timeline.ts` only if needed for import shape)
|
||||
- Replace `consultations.find(...)` for pinned role accordion with career entity lookup from canonical timeline exports (or mapped career consultation export already derived from timeline career entities).
|
||||
- Acceptance in this task: no new dependency on combined timeline entities for role detail surface.
|
||||
**Files:** `src/hooks/useTimelineAnimation.ts` (NEW), `src/components/constellation/types.ts`, `src/components/constellation/constants.ts`
|
||||
|
||||
### Acceptance checks (task-local)
|
||||
- Desktop pointer:
|
||||
- Hovering any visible role/skill node reliably triggers graph highlight without dead zones.
|
||||
- Moving pointer node-to-node does not cause highlight flash-to-none.
|
||||
- Keyboard:
|
||||
- Tab reaches node controls in intended order.
|
||||
- Focus highlights target node and role cards (for role nodes).
|
||||
- Blur returns to fallback highlight state (external hover or pinned) without forced reset.
|
||||
- Touch/coarse pointer:
|
||||
- Tap node pins/unpins.
|
||||
- Tap background clears pinned state and timeline highlight.
|
||||
- Cross-surface coherence:
|
||||
- Timeline card hover and graph hover no longer fight each other during transitions.
|
||||
**Core Architecture:**
|
||||
|
||||
### Handoff note to builder
|
||||
- Keep the patch minimal and behavior-focused.
|
||||
- Do not combine token/font changes or broad timeline refactors into this task; defer those to `task-1771246519-92f0`.
|
||||
The animation hook manages a state machine that reveals nodes chronologically. All nodes exist in the D3 simulation from the start (positions stable) but are hidden via `opacity: 0`. The hook uses `requestAnimationFrame` with a timestamp-based scheduler.
|
||||
|
||||
- 2026-02-16: Completed Task B (`task-1771246519-1e54`).
|
||||
- Updated `src/components/CareerConstellation.tsx` to remove pointer interception from accessibility overlay controls (`pointerEvents: 'none'` on invisible positioned buttons) so SVG hover handlers remain authoritative for desktop pointer input.
|
||||
- Added fallback resolvers (`resolveGraphFallback`, `resolveRoleFallback`) and wired them into node `mouseleave`, keyboard-control `blur`, and coarse-pointer skill pin paths to prevent role-highlight reset flicker.
|
||||
- Kept coarse-pointer tap-to-pin behavior and background clear behavior intact while preserving keyboard focus/Enter/Space activation.
|
||||
- Replaced mobile pinned role accordion dependency on `consultations` with canonical `timelineCareerEntities` lookup to keep role detail semantics aligned with career-only timeline scope.
|
||||
- Validation: `npm run lint` (pass, 2 existing warnings), `npm run typecheck` (pass), `npm run build` (pass).
|
||||
**`src/components/constellation/types.ts` additions:**
|
||||
```ts
|
||||
export type AnimationState = 'IDLE' | 'PLAYING' | 'PAUSED' | 'HOLDING' | 'RESETTING'
|
||||
|
||||
## Atomic Execution Plan: task-1771246519-fd59 (Backpressure + Manual Review Evidence)
|
||||
export interface AnimationStep {
|
||||
entityId: string // The role/education entity being revealed
|
||||
startYear: number // For year indicator display
|
||||
skillIds: string[] // Skills to reveal with this entity
|
||||
newSkillIds: string[] // Skills not yet visible (first appearance)
|
||||
reinforcedSkillIds: string[] // Skills already visible (get pulse)
|
||||
linkPairs: Array<{ source: string; target: string }> // Links to draw on
|
||||
}
|
||||
```
|
||||
|
||||
### Scope for this execution
|
||||
- Primary files: `.ralph/review.md`, `.ralph/plan.md`
|
||||
- Allowed supporting touchpoints: command outputs from `npm run lint`, `npm run typecheck`, `npm run build`, plus any available audit/coverage/complexity/duplication scripts or documented equivalents.
|
||||
- Explicitly out of scope for this task: feature implementation work in `src/` (handled by `task-1771246519-92f0` and prior tasks).
|
||||
**`src/components/constellation/constants.ts` additions:**
|
||||
```ts
|
||||
// Timeline animation
|
||||
export const ANIM_ENTITY_REVEAL_MS = 600 // Role/education node scale-in duration
|
||||
export const ANIM_SKILL_REVEAL_MS = 350 // New skill node scale-in duration
|
||||
export const ANIM_SKILL_STAGGER_MS = 60 // Stagger between skills within a step
|
||||
export const ANIM_LINK_DRAW_MS = 300 // Link stroke-dashoffset draw-on
|
||||
export const ANIM_LINK_STAGGER_MS = 40 // Stagger between links
|
||||
export const ANIM_REINFORCEMENT_MS = 350 // Pulse duration for already-visible skills
|
||||
export const ANIM_STEP_GAP_MS = 400 // Pause between steps (entities)
|
||||
export const ANIM_HOLD_MS = 3000 // Hold at end before reset
|
||||
export const ANIM_RESET_MS = 400 // Fade-all duration
|
||||
export const ANIM_RESTART_DELAY_MS = 200 // Pause after reset before replaying
|
||||
export const ANIM_INTERACTION_RESUME_MS = 800 // Resume delay after interaction ends
|
||||
export const ANIM_SETTLE_ALPHA = 0.05 // Simulation alpha threshold to start
|
||||
```
|
||||
|
||||
### Objective for this task
|
||||
- Produce reviewer-visible evidence that manual behavior checks were executed against the current remediation state.
|
||||
- Satisfy pending `build.blocked` contract by preparing a compliant `build.done` payload with explicit status fields.
|
||||
**`src/hooks/useTimelineAnimation.ts` — Hook Design:**
|
||||
|
||||
### Required evidence contract
|
||||
The next `build.done` event payload must include all required fields:
|
||||
- `tests: <status>`
|
||||
- `lint: <status>`
|
||||
- `typecheck: <status>`
|
||||
- `audit: <status>`
|
||||
- `coverage: <status>`
|
||||
- `complexity: <value or status>`
|
||||
- `duplication: <status>`
|
||||
- Optional when available: `performance: <status>`, `specs: <status>`
|
||||
```ts
|
||||
export function useTimelineAnimation(deps: {
|
||||
nodeSelectionRef: React.MutableRefObject<d3.Selection<...> | null>
|
||||
linkSelectionRef: React.MutableRefObject<d3.Selection<...> | null>
|
||||
simulationRef: React.MutableRefObject<d3.Simulation<...> | null>
|
||||
nodesRef: React.MutableRefObject<SimNode[]>
|
||||
connectedMapRef: React.MutableRefObject<Map<string, Set<string>>>
|
||||
skillRestRadiiRef: React.MutableRefObject<Map<string, number>>
|
||||
srDefault: number
|
||||
isMobile: boolean
|
||||
sf: number
|
||||
dimensionsTrigger: number
|
||||
}): {
|
||||
animationStateRef: React.MutableRefObject<AnimationState>
|
||||
visibleNodeIdsRef: React.MutableRefObject<Set<string>>
|
||||
isPlaying: boolean // React state for UI button
|
||||
togglePlayPause: () => void
|
||||
pauseForInteraction: () => void
|
||||
resumeAfterInteraction: () => void
|
||||
}
|
||||
```
|
||||
|
||||
If a metric is not implemented in this repository, report it explicitly as `not-configured` with a short qualifier in `.ralph/review.md`; do not omit the field from `build.done`.
|
||||
**Animation Step Sequence:**
|
||||
|
||||
### Implementation steps for builder/reviewer
|
||||
1. Run backpressure checks and capture concrete outcomes.
|
||||
- Execute:
|
||||
- `npm run lint`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- Discover audit/coverage/complexity/duplication command availability from `package.json` and existing tooling files; run what exists.
|
||||
- For unavailable gates, record `not-configured` with one-line rationale tied to repository state.
|
||||
1. **Pre-compute steps** from `timelineEntities` sorted oldest-first:
|
||||
```
|
||||
A-Levels (2009) → MPharm (2011) → Pre-Reg (2015) → Duty Manager (2016) →
|
||||
Pharmacy Manager (2017) → HCD Pharm (2022) → Deputy Head (2024) → Interim Head (2025)
|
||||
```
|
||||
|
||||
2. Record manual behavior verification in `.ralph/review.md`.
|
||||
- Add a concise section with date/time and environment assumptions (desktop pointer + coarse pointer + keyboard path tested).
|
||||
- Record pass/fail notes for:
|
||||
- Desktop hover on role nodes and skill nodes (fill and border hit areas).
|
||||
- Graph/timeline cross-highlight coherence.
|
||||
- Touch/coarse-pointer tap-to-pin and background clear.
|
||||
- Keyboard tab/focus/Enter/Space behavior.
|
||||
- Timeline ordering parity against work-experience chronology.
|
||||
- If any item fails, include minimal repro steps and keep task open.
|
||||
2. **For each step**, determine:
|
||||
- `newSkillIds`: skills not in `visibleNodeIds` set yet
|
||||
- `reinforcedSkillIds`: skills already in `visibleNodeIds` set
|
||||
- `linkPairs`: all links from this entity
|
||||
|
||||
3. Prepare compliant `build.done` summary string.
|
||||
- Construct one-line payload covering every required field in the contract.
|
||||
- Example shape (statuses illustrative only):
|
||||
- `tests: pass, lint: pass, typecheck: pass, audit: not-configured, coverage: not-configured, complexity: not-configured, duplication: not-configured, performance: optional, specs: optional`
|
||||
3. **Reveal sequence per step** (all via D3 transitions):
|
||||
a. Entity node: scale from 0 with `ease-out-back` (custom easing or D3 `d3.easeBackOut`)
|
||||
b. Entity connector: fade in
|
||||
c. New skills: scale from 0 with `ease-out`, staggered by `ANIM_SKILL_STAGGER_MS`
|
||||
d. Reinforced skills: pulse `transform: scale(1.3)` → `scale(1.0)` over `ANIM_REINFORCEMENT_MS`
|
||||
e. Links: draw on via `stroke-dashoffset` animation, staggered
|
||||
f. Update `visibleNodeIds` set
|
||||
g. Wait `ANIM_STEP_GAP_MS` before next step
|
||||
|
||||
### Acceptance checks (task-local)
|
||||
- `.ralph/review.md` contains dated manual verification notes for all required interaction categories.
|
||||
- Backpressure command outcomes are explicitly documented (pass/fail/not-configured).
|
||||
- `build.done` payload draft includes every required field and uses no missing keys.
|
||||
- No source feature code changes are introduced in this task.
|
||||
4. **State machine in refs:**
|
||||
- `animationStateRef`: current state
|
||||
- `currentStepRef`: index of current entity step
|
||||
- `rafIdRef`: requestAnimationFrame ID for cleanup
|
||||
- `visibleNodeIdsRef`: Set of revealed node IDs (shared with highlight system)
|
||||
|
||||
- 2026-02-16: Completed Task D (`task-1771246519-fd59`).
|
||||
- Added a dated backpressure/manual-evidence addendum to `.ralph/review.md` with explicit outcomes for lint/typecheck/build/audit.
|
||||
- Documented required `build.done` field statuses with no omitted keys:
|
||||
- `tests: not-configured, lint: pass, typecheck: pass, audit: pass, coverage: not-configured, complexity: not-configured, duplication: not-configured, performance: not-configured, specs: not-configured`
|
||||
- Confirmed this iteration was evidence-only (no `src/` feature edits) and preserved existing reviewer manual-interaction validation record.
|
||||
5. **Loop cycle:**
|
||||
- After all steps: state → `HOLDING`, wait `ANIM_HOLD_MS`
|
||||
- Fade all nodes to opacity 0 over `ANIM_RESET_MS`: state → `RESETTING`
|
||||
- Clear `visibleNodeIds`, wait `ANIM_RESTART_DELAY_MS`
|
||||
- State → `PLAYING`, restart from step 0
|
||||
|
||||
## Atomic Execution Plan: task-1771246519-92f0 (Timeline Ordering Parity + Token Alignment)
|
||||
**Key implementation details:**
|
||||
|
||||
### Scope for this execution
|
||||
- Primary files: `src/components/TimelineInterventionsSubsection.tsx`, `src/components/DashboardLayout.tsx`, `src/data/timeline.ts`
|
||||
- Secondary files (only if needed to remove remaining invalid token usage in timeline paths): `src/components/WorkExperienceSubsection.tsx`, `src/index.css`
|
||||
- Explicitly out of scope: pointer/focus architecture changes in `CareerConstellation` unless a regression fix is strictly required.
|
||||
- **rAF scheduler:** The main loop uses `requestAnimationFrame` with accumulated elapsed time. Each frame checks if enough time has passed to advance to the next phase of the current step. This avoids setTimeout chains and gives smooth control.
|
||||
|
||||
### Current residual gaps (post Task B/D)
|
||||
- `TimelineInterventionsSubsection` still opens detail panels through `consultations` compatibility import instead of canonical timeline-derived exports.
|
||||
- `DashboardLayout` still uses `consultations` for role click resolution and "Last Consultation" content derivation (`consultations[0]`), which leaves chronology semantics coupled to a compatibility layer rather than explicit career timeline selectors.
|
||||
- Timeline-adjacent components still contain invalid token references (`fontFamily: 'var(--font-mono)'`) despite canonical mono tokens being `--font-geist-mono` / `--font-mono-dashboard`.
|
||||
- Legacy duplicate path `WorkExperienceSubsection` remains in repo and still carries `var(--font-mono)` usage; while currently not mounted, leaving unresolved token drift risks reintroducing inconsistency if re-enabled.
|
||||
- **D3 transitions for node reveal:** Rather than managing every frame in rAF, use D3 transitions for the actual visual changes (they handle interpolation). The rAF scheduler just triggers step transitions at the right time and manages state.
|
||||
|
||||
### Implementation steps for builder
|
||||
1. Align timeline detail-panel lookups to canonical timeline exports.
|
||||
- File: `src/components/TimelineInterventionsSubsection.tsx`
|
||||
- Replace `consultations` import/lookup with canonical timeline-derived source (`timelineConsultations` or direct mapping from `timelineCareerEntities`).
|
||||
- Preserve behavior: only career entities open `career-role` panel payloads, and non-career entries safely no-op for role panel opening.
|
||||
- **Initial hidden state:** On mount (or dimension change), hide ALL entity/skill nodes and links at `opacity: 0`. Skill nodes also get `r: 0` on their circles. This replaces the Phase 2 entry animation hiding logic.
|
||||
|
||||
2. Enforce explicit career-order source in dashboard chronology controls.
|
||||
- File: `src/components/DashboardLayout.tsx`
|
||||
- Replace compatibility-layer lookups for:
|
||||
- role click (`handleRoleClick`)
|
||||
- last-consultation summary source (`consultations[0]`)
|
||||
with canonical career timeline ordering (`timelineCareerEntities` + deterministic consultation mapping).
|
||||
- Ensure "Most recent role" reflects the first canonical career entity by sorted timeline order, matching constellation role chronology.
|
||||
- **Wait for simulation:** Don't start animation until `simulationRef.current.alpha() < ANIM_SETTLE_ALPHA`. Check this in the rAF loop's first frame.
|
||||
|
||||
3. Complete mono token cleanup for chart/timeline-adjacent UI.
|
||||
- Files: `src/components/TimelineInterventionsSubsection.tsx`, `src/components/WorkExperienceSubsection.tsx` (if retained), optional `src/index.css`
|
||||
- Replace `var(--font-mono)` usage with canonical mono token (`var(--font-geist-mono)` or `var(--font-mono-dashboard)`), avoiding introduction of new ad-hoc token names.
|
||||
- Keep UI/body text tokens unchanged (no redesign).
|
||||
- **Cleanup:** On unmount or dimension change, cancel rAF, stop all D3 transitions on selections.
|
||||
|
||||
4. Clarify legacy/duplicate timeline path handling.
|
||||
- File: `src/components/WorkExperienceSubsection.tsx` (and/or `.ralph/review.md` note)
|
||||
- Choose one minimal path and document it:
|
||||
- either normalize remaining tokens in this unused component, or
|
||||
- explicitly justify that it is unused/deprecated and excluded from runtime parity checks.
|
||||
- Do not do a broad delete/refactor in this task.
|
||||
**Relationship to highlight system:**
|
||||
- The hook exposes `visibleNodeIdsRef` — the highlight system reads this to know which nodes can be highlighted
|
||||
- The hook exposes `pauseForInteraction()` and `resumeAfterInteraction()` — called by interaction handlers
|
||||
- When paused for interaction, current step freezes but visible nodes remain visible
|
||||
|
||||
5. Regression-safe validation.
|
||||
- Run:
|
||||
- `npm run lint`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- Manual sanity checks to capture in `.ralph/review.md`:
|
||||
- Timeline ordering parity: top chronology role matches top constellation role.
|
||||
- Role-card hover and graph hover remain coherent after data-source alignment.
|
||||
- Node hover over fill area remains reliable (no regression of Task B fix).
|
||||
- Last consultation card reflects canonical latest career entry.
|
||||
---
|
||||
|
||||
### Acceptance checks (task-local)
|
||||
- No chart/timeline-adjacent component references `var(--font-mono)`.
|
||||
- Timeline and dashboard role-detail lookups use canonical timeline career sources, not legacy compatibility imports in component logic.
|
||||
- Latest-role summary and chronology ordering are consistent with `timelineCareerEntities` ordering semantics.
|
||||
- Hover/focus interaction behavior from Task B remains intact.
|
||||
- `npm run lint`, `npm run typecheck`, and `npm run build` pass.
|
||||
### Task 3: Visual — Entry animation reveal effects (task-1771251477-81a2)
|
||||
|
||||
### Handoff note to builder
|
||||
- Keep this patch data-source/token focused; avoid reworking D3 forces or node event wiring unless a direct regression is detected.
|
||||
- If a legacy path is left in place, add explicit rationale in `.ralph/review.md` so success criterion "resolved or clearly justified" is satisfied.
|
||||
**Files:** `src/hooks/useForceSimulation.ts`, `src/hooks/useTimelineAnimation.ts`
|
||||
|
||||
- 2026-02-16: Completed Task C (`task-1771246519-92f0`).
|
||||
- Updated `src/components/TimelineInterventionsSubsection.tsx` to use canonical `timelineConsultations` lookup for role detail-panel opening instead of legacy `consultations` import.
|
||||
- Updated `src/components/DashboardLayout.tsx` to source "Last Consultation" and role-click resolution from canonical `timelineConsultations` (including memoized id map) to align chronology semantics with career timeline selectors.
|
||||
- Replaced remaining `var(--font-mono)` usage in timeline-adjacent components with canonical `var(--font-geist-mono)`:
|
||||
- `src/components/TimelineInterventionsSubsection.tsx`
|
||||
- `src/components/WorkExperienceSubsection.tsx` (legacy path retained, token-normalized to prevent style drift if re-enabled).
|
||||
- Validation: `npm run lint` (pass, 2 existing warnings), `npm run typecheck` (pass), `npm run build` (pass).
|
||||
**`src/hooks/useForceSimulation.ts` changes:**
|
||||
|
||||
## Atomic Execution Plan: task-1771247453-c78f (Resolve build.blocked Backpressure Gate)
|
||||
1. **Remove Phase 2 entry animation** — Delete the entire `maybeRunEntryAnimation` function and its related code (lines 479-559):
|
||||
- Remove initial hidden state setting (lines 479-487)
|
||||
- Remove `entryAnimationRan` flag and `maybeRunEntryAnimation` function (lines 489-547)
|
||||
- Remove the `maybeRunEntryAnimation()` call from tick handler (line 558)
|
||||
- The entry animation constants can remain in `constants.ts` (no harm, or remove if desired)
|
||||
|
||||
### Scope for this execution
|
||||
- Primary files: `.ralph/review.md`, `.ralph/plan.md` (progress note only if needed)
|
||||
- Event output: one compliant `build.done` payload from builder after evidence capture
|
||||
- Explicitly out of scope: `src/` feature changes (only revisit if a gate fails and fix is required)
|
||||
2. **Year indicator SVG element** — Add a text element for displaying current year during animation:
|
||||
- Append to SVG (after background rect, before timeline guides):
|
||||
```ts
|
||||
const yearIndicator = svg.append('text')
|
||||
.attr('class', 'year-indicator')
|
||||
.attr('x', sidePadding + 8)
|
||||
.attr('y', topPadding - 4)
|
||||
.attr('font-size', isMobile ? '18' : `${Math.round(24 * sf)}`)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.attr('opacity', 0)
|
||||
```
|
||||
- Expose via a ref so the animation hook can update it
|
||||
|
||||
### Why this task is open
|
||||
- Runtime queue indicates `build.blocked` still pending even though prior remediation and checks were completed.
|
||||
- The required closure path is a builder pass that reasserts gate evidence and emits a `build.done` payload with all mandatory fields present.
|
||||
**`src/hooks/useTimelineAnimation.ts` — Reveal effects:**
|
||||
|
||||
### Builder steps
|
||||
1. Re-run required gates in current workspace state.
|
||||
- `npm run lint`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- `npm audit --omit=dev --json`
|
||||
3. **Entity node reveal:** Scale from 0 with `d3.easeBackOut`:
|
||||
```ts
|
||||
// Select the entity's <g> node, set initial transform-origin
|
||||
entityGroup
|
||||
.attr('opacity', 0)
|
||||
.attr('transform', d => `translate(${d.x},${d.y}) scale(0)`)
|
||||
.transition()
|
||||
.duration(ANIM_ENTITY_REVEAL_MS)
|
||||
.ease(d3.easeBackOut.overshoot(1.2))
|
||||
.attr('opacity', 1)
|
||||
.attr('transform', d => `translate(${d.x},${d.y}) scale(1)`)
|
||||
```
|
||||
**Note:** D3 `<g>` transform includes both translate and scale. The tick handler normally sets `transform: translate(x,y)`. During animation, we need to temporarily override — use an `animatingNodes` Set to skip tick-driven transform updates for nodes mid-transition.
|
||||
|
||||
2. Reconcile optional/non-configured gates from repository tooling.
|
||||
- Confirm presence/absence of scripts/tooling for:
|
||||
- `tests`
|
||||
- `coverage`
|
||||
- `complexity`
|
||||
- `duplication`
|
||||
- optional `performance`
|
||||
- optional `specs`
|
||||
- If absent, report `not-configured` (do not omit keys).
|
||||
**Better approach:** Don't fight the tick handler. Instead, keep the group at `translate(x,y)` via tick, and animate the child elements' opacity + the circle/rect scale:
|
||||
- Set entity group `opacity: 0` initially
|
||||
- Transition group `opacity: 0 → 1`
|
||||
- For the `rect.node-circle` inside, animate from `transform: scale(0)` to `scale(1)` using CSS transform-origin center
|
||||
- This avoids conflicting with the tick handler's group transform
|
||||
|
||||
3. Update `.ralph/review.md` with dated backpressure evidence.
|
||||
- Include command outcomes and any caveats (for example, lint warnings vs errors).
|
||||
- Include explicit line-item statuses for every required `build.done` field.
|
||||
4. **Skill node reveal:** Scale `.node-circle` from `r: 0`:
|
||||
```ts
|
||||
skillGroup.attr('opacity', 0)
|
||||
skillGroup.transition().duration(ANIM_SKILL_REVEAL_MS).attr('opacity', 1)
|
||||
skillGroup.select('.node-circle')
|
||||
.attr('r', 0)
|
||||
.transition().duration(ANIM_SKILL_REVEAL_MS).ease(d3.easeBackOut)
|
||||
.attr('r', restRadius)
|
||||
```
|
||||
|
||||
4. Emit one compliant `build.done` payload.
|
||||
- Required key set (no omissions):
|
||||
- `tests`, `lint`, `typecheck`, `audit`, `coverage`, `complexity`, `duplication`
|
||||
- Optional keys when tracked:
|
||||
- `performance`, `specs`
|
||||
- Example payload shape:
|
||||
- `tests: not-configured, lint: pass, typecheck: pass, audit: pass, coverage: not-configured, complexity: not-configured, duplication: not-configured, performance: not-configured, specs: not-configured`
|
||||
5. **Link draw-on:** Stroke-dashoffset animation:
|
||||
```ts
|
||||
linkEl.attr('opacity', 1)
|
||||
const length = linkEl.node().getTotalLength()
|
||||
linkEl
|
||||
.attr('stroke-dasharray', `${length} ${length}`)
|
||||
.attr('stroke-dashoffset', length)
|
||||
.transition().duration(ANIM_LINK_DRAW_MS)
|
||||
.attr('stroke-dashoffset', 0)
|
||||
.on('end', function() {
|
||||
d3.select(this).attr('stroke-dasharray', null).attr('stroke-dashoffset', null)
|
||||
})
|
||||
```
|
||||
|
||||
### Acceptance checks (task-local)
|
||||
- Required commands executed and outcomes recorded.
|
||||
- `.ralph/review.md` contains a fresh dated evidence entry for this closure pass.
|
||||
- `build.done` emitted with full required key contract (and optional keys included if reported).
|
||||
- No unrelated feature/refactor edits are introduced.
|
||||
6. **Reinforcement pulse** for already-visible skills:
|
||||
```ts
|
||||
skillCircle
|
||||
.transition().duration(ANIM_REINFORCEMENT_MS / 2)
|
||||
.attr('r', restRadius * 1.3)
|
||||
.transition().duration(ANIM_REINFORCEMENT_MS / 2)
|
||||
.attr('r', restRadius)
|
||||
```
|
||||
|
||||
- 2026-02-16T13:12:56Z: Completed Task `task-1771247453-c78f` (resolve `build.blocked` backpressure gate).
|
||||
- Re-ran required gates in current workspace state: `npm run lint`, `npm run typecheck`, `npm run build`, `npm audit --omit=dev --json`.
|
||||
- Confirmed required contract field statuses for next `build.done` payload (including explicit `not-configured` entries for unavailable gates).
|
||||
- Updated `.ralph/review.md` with fresh dated evidence addendum for closure.
|
||||
- No `src/` implementation edits required; objective remains satisfied from prior completed remediation tasks.
|
||||
7. **Year indicator update:**
|
||||
```ts
|
||||
yearIndicator
|
||||
.text(step.startYear)
|
||||
.transition().duration(200)
|
||||
.attr('opacity', 0.6)
|
||||
```
|
||||
|
||||
8. **Reset animation** (at loop end):
|
||||
```ts
|
||||
// Fade everything out
|
||||
nodeSelection.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
linkSelection.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
yearIndicator.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
// Also reset skill radii to 0, connector opacity to 0
|
||||
```
|
||||
|
||||
**Pitfall — Tick handler conflicts:**
|
||||
The tick handler (in `useForceSimulation`) calls `nodeSelection.attr('transform', ...)` every tick. During animation, nodes that are `opacity: 0` still get positioned — that's fine (we want stable positions). The issue is if we animate `transform` on the group — tick will override it. **Solution:** Only animate opacity and child element attributes (r, scale via CSS), never the group's `translate` transform. The group transform is exclusively managed by the tick handler.
|
||||
|
||||
**Pitfall — Link path changes during animation:**
|
||||
Links update their `d` attribute every tick. `stroke-dasharray` based on `getTotalLength()` will be slightly wrong as positions shift. Since we wait for alpha < 0.05, positions are nearly stable and the error is negligible. Clean up dasharray after animation ends.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Integration — Wire animation to highlight system (task-1771251479-1473)
|
||||
|
||||
**Files:** `src/hooks/useConstellationHighlight.ts`, `src/hooks/useConstellationInteraction.ts`, `src/components/constellation/CareerConstellation.tsx`
|
||||
|
||||
**Multiplicative Opacity Model:**
|
||||
|
||||
`finalOpacity = animationVisibility × highlightEmphasis`
|
||||
|
||||
- `animationVisibility`: 0 (hidden/not-yet-revealed) or target opacity (1.0 for groups, 0.35 for skill fills, etc.)
|
||||
- `highlightEmphasis`: 1.0 (normal/connected) or 0.15 (dimmed)
|
||||
- Only operate highlight on nodes where `animationVisibility > 0`
|
||||
|
||||
**`src/hooks/useConstellationHighlight.ts` changes:**
|
||||
|
||||
1. **Add `visibleNodeIdsRef` to deps:**
|
||||
```ts
|
||||
visibleNodeIdsRef?: React.MutableRefObject<Set<string>>
|
||||
```
|
||||
|
||||
2. **Guard highlight against unrevealed nodes:**
|
||||
In `applyGraphHighlight`, when `activeNodeId` is set:
|
||||
```ts
|
||||
const visibleIds = deps.visibleNodeIdsRef?.current
|
||||
const isVisible = (id: string) => !visibleIds || visibleIds.has(id)
|
||||
|
||||
// Only dim visible nodes; keep unrevealed at opacity 0
|
||||
nodeSelection.style('opacity', d => {
|
||||
if (!isVisible(d.id)) return '0'
|
||||
return isInGroup(d.id) ? '1' : '0.15'
|
||||
})
|
||||
```
|
||||
|
||||
When resetting (no `activeNodeId`):
|
||||
```ts
|
||||
nodeSelection.style('opacity', d => {
|
||||
if (!isVisible(d.id)) return '0'
|
||||
return '1'
|
||||
})
|
||||
```
|
||||
|
||||
3. **Link visibility guard:**
|
||||
```ts
|
||||
linkSelection.attr('opacity', l => {
|
||||
const src = /* resolve id */
|
||||
const tgt = /* resolve id */
|
||||
if (!isVisible(src) || !isVisible(tgt)) return 0
|
||||
// normal highlight opacity
|
||||
})
|
||||
```
|
||||
|
||||
**`src/hooks/useConstellationInteraction.ts` changes:**
|
||||
|
||||
4. **Pause animation on interaction:**
|
||||
Add `pauseForInteraction` and `resumeAfterInteraction` to deps:
|
||||
```ts
|
||||
pauseForInteraction?: () => void
|
||||
resumeAfterInteraction?: () => void
|
||||
```
|
||||
|
||||
In `mouseenter.interaction`:
|
||||
```ts
|
||||
deps.pauseForInteraction?.()
|
||||
```
|
||||
|
||||
In `mouseleave.interaction`:
|
||||
```ts
|
||||
deps.resumeAfterInteraction?.()
|
||||
```
|
||||
|
||||
In `click.interaction` for touch (pin):
|
||||
```ts
|
||||
deps.pauseForInteraction?.()
|
||||
// On unpin (click same node or background):
|
||||
deps.resumeAfterInteraction?.()
|
||||
```
|
||||
|
||||
In background click (`.bg-rect` click handler):
|
||||
```ts
|
||||
deps.resumeAfterInteraction?.()
|
||||
```
|
||||
|
||||
**`src/components/constellation/CareerConstellation.tsx` changes:**
|
||||
|
||||
5. **Wire useTimelineAnimation hook:**
|
||||
```ts
|
||||
const {
|
||||
animationStateRef,
|
||||
visibleNodeIdsRef,
|
||||
isPlaying,
|
||||
togglePlayPause,
|
||||
pauseForInteraction,
|
||||
resumeAfterInteraction,
|
||||
} = useTimelineAnimation({
|
||||
nodeSelectionRef,
|
||||
linkSelectionRef,
|
||||
simulationRef: sim.simulationRef,
|
||||
nodesRef,
|
||||
connectedMapRef,
|
||||
skillRestRadiiRef,
|
||||
srDefault,
|
||||
isMobile,
|
||||
sf,
|
||||
dimensionsTrigger: dimensions.width + dimensions.height,
|
||||
})
|
||||
```
|
||||
|
||||
6. **Pass `visibleNodeIdsRef` to highlight hook deps**
|
||||
|
||||
7. **Pass `pauseForInteraction` and `resumeAfterInteraction` to interaction hook deps**
|
||||
|
||||
8. **Sync `simulationRef`** — the orchestrator needs to pass `sim.simulationRef` to the animation hook
|
||||
|
||||
**Orchestrator line count impact:** Adding the animation hook call (~12 lines), play/pause button (~10 lines), and additional deps (~4 lines) adds ~26 lines. Current orchestrator is 294 lines → ~320 lines. We can offset by:
|
||||
- Moving `buildScreenReaderDescription()` to a separate small utility (saves ~15 lines)
|
||||
- Or inlining the play/pause button compactly
|
||||
|
||||
Target: keep orchestrator under 330 lines (slight relaxation from 300 given the significant new functionality).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Accessibility — reduced-motion + play/pause button (task-1771251482-f0e9)
|
||||
|
||||
**Files:** `src/hooks/useTimelineAnimation.ts`, `src/components/constellation/CareerConstellation.tsx`
|
||||
|
||||
**Reduced motion (in `useTimelineAnimation.ts`):**
|
||||
|
||||
1. **If `prefersReducedMotion`:**
|
||||
- Skip the entire animation system
|
||||
- Set all nodes + links to visible immediately (their final state)
|
||||
- `visibleNodeIdsRef` contains all node IDs from start
|
||||
- `isPlaying` is `false`, `togglePlayPause` is a no-op
|
||||
- The hook returns early after setting initial visible state
|
||||
|
||||
2. **Implementation:**
|
||||
```ts
|
||||
if (prefersReducedMotion) {
|
||||
// Show everything immediately
|
||||
visibleNodeIdsRef.current = new Set(allNodeIds)
|
||||
animationStateRef.current = 'IDLE'
|
||||
// Set all node opacities to target values
|
||||
nodeSelectionRef.current?.style('opacity', '1')
|
||||
linkSelectionRef.current?.attr('opacity', 1)
|
||||
// Restore skill radii
|
||||
nodeSelectionRef.current?.filter(d => d.type === 'skill')
|
||||
.select('.node-circle')
|
||||
.attr('r', d => skillRestRadiiRef.current.get(d.id) ?? srDefault)
|
||||
return { isPlaying: false, ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Play/Pause Button (in `CareerConstellation.tsx`):**
|
||||
|
||||
3. **JSX — positioned bottom-right of SVG area:**
|
||||
```tsx
|
||||
{!prefersReducedMotion && (
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
aria-label={isPlaying ? 'Pause animation' : 'Play animation'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
border: '1px solid var(--border-light)',
|
||||
background: 'var(--surface)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: 0.6,
|
||||
transition: 'opacity 150ms ease',
|
||||
// Larger touch target on mobile
|
||||
...(isMobile && { width: 44, height: 44, bottom: 8, right: 8 }),
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
|
||||
onMouseLeave={e => (e.currentTarget.style.opacity = '0.6')}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="var(--text-secondary)">
|
||||
<rect x="2" y="1" width="4" height="12" rx="1" />
|
||||
<rect x="8" y="1" width="4" height="12" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="var(--text-secondary)">
|
||||
<polygon points="3,1 13,7 3,13" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
4. **Interaction behavior:**
|
||||
- Explicit pause via button: stays paused until user clicks play
|
||||
- This is different from interaction-pause (hover/tap), which auto-resumes after 800ms
|
||||
- The `togglePlayPause` in the hook must distinguish: set a `userPausedRef` flag
|
||||
- When `userPausedRef` is true, `resumeAfterInteraction()` does NOT resume
|
||||
- Only `togglePlayPause()` can unpause when user-paused
|
||||
|
||||
5. **During paused state, all existing interactions work normally:**
|
||||
- Mobile accordion works (pinned entity visible)
|
||||
- Keyboard navigation works (buttons overlay present for visible nodes)
|
||||
- Click → detail panel works
|
||||
- Highlight system operates on visible nodes only
|
||||
|
||||
---
|
||||
|
||||
## Build & Verification Order
|
||||
|
||||
1. **Task 1** — Data changes (timeline.ts + pmr.ts type update). Run typecheck to catch all downstream type errors.
|
||||
2. **Task 2** — Create useTimelineAnimation hook + new constants + types. Typecheck.
|
||||
3. **Task 3** — Remove Phase 2 entry animation from useForceSimulation, add year indicator element. Wire reveal effects into animation hook. Typecheck + build.
|
||||
4. **Task 4** — Wire highlight + interaction hooks to animation. Update orchestrator. Typecheck + build.
|
||||
5. **Task 5** — Reduced-motion path + play/pause button. Full validation: `npm run lint && npm run typecheck && npm run build`.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls to Avoid
|
||||
|
||||
1. **Tick handler transform conflict** — Never animate the group's `translate` transform in the animation hook. The tick handler owns group transforms. Animate child element attributes (opacity, r, fill-opacity) only.
|
||||
|
||||
2. **D3 transition interruption** — If a new transition starts on the same element while one is running, D3 interrupts the old one. The animation step scheduler must wait for transitions to complete before starting the next step. Use `transition.on('end', ...)` or track completion.
|
||||
|
||||
3. **stale closure in rAF** — The rAF callback captures refs at creation time. Always read from `.current` inside the rAF callback, never close over state values.
|
||||
|
||||
4. **Link opacity during animation** — Links between two nodes should only become visible when BOTH source and target are in `visibleNodeIds`. Check both ends before revealing.
|
||||
|
||||
5. **Skill radius during animation** — When a skill node is first revealed, its `.node-circle` starts at `r: 0` and animates to its rest radius. The reinforcement pulse must use the correct rest radius from `skillRestRadii` map.
|
||||
|
||||
6. **Education node rendering** — `useForceSimulation.ts` has multiple `.filter(d => d.type === 'role')` calls for rendering role-specific elements (rect, text, focus-ring, connectors). All of these must be updated to `.filter(d => d.type === 'role' || d.type === 'education')`.
|
||||
|
||||
7. **connectedMap for education** — Education entities link to skills just like career entities. The connectedMap is built from `constellationLinks` which will now include education links. No special handling needed.
|
||||
|
||||
8. **Orchestrator line count** — The orchestrator will grow beyond 300 lines. Extract `buildScreenReaderDescription()` to a utility file to reclaim space. Alternatively, accept ~320-330 lines as reasonable given the new functionality.
|
||||
|
||||
9. **Dimension changes during animation** — When dimensions change, the simulation re-creates. The animation hook must detect this (via `dimensionsTrigger` dep) and restart from scratch — cancel current rAF, reset state to IDLE, re-hide all nodes, wait for simulation to settle, then start playing.
|
||||
|
||||
10. **AccessibleNodeOverlay** — Currently renders buttons for all `constellationNodes`. After adding education entities, these will automatically get buttons too. The button overlay should only show buttons for VISIBLE nodes during animation — add a `visibleNodeIds` filter, or keep all buttons but set invisible ones to `visibility: hidden`.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server (localhost:5173)
|
||||
npm run build # TypeScript compile + Vite production build
|
||||
npm run preview # Preview production build locally
|
||||
npm run lint # ESLint
|
||||
npm run typecheck # TypeScript checks (no emit)
|
||||
npm run generate-embeddings # Regenerate semantic search embeddings (src/data/embeddings.json)
|
||||
```
|
||||
|
||||
**Validation gate (run before any PR):** `npm run lint && npm run typecheck && npm run build`
|
||||
|
||||
No automated test framework — lint, typecheck, and build are the quality gates. For UI changes, verify manually (responsive behavior, accessibility, keyboard navigation).
|
||||
|
||||
## Architecture
|
||||
|
||||
**Interactive CV/portfolio** with a PMR (patient medical record) interface aesthetic. Three-phase UX: terminal boot → ECG heartbeat → dashboard.
|
||||
|
||||
### App lifecycle (`src/App.tsx`)
|
||||
Phase orchestrator managing: BootSequence → ECGAnimation → LoginScreen → DashboardLayout
|
||||
|
||||
### Data flow
|
||||
- **Canonical source:** `src/data/timeline.ts` — all career + education entities live here
|
||||
- **Derived data:** `constellation.ts` builds D3 graph data from timeline; `consultations.ts` re-exports for legacy consumers; `tags.ts` derived from skills; `kpis.ts` standalone
|
||||
- **Types:** `src/types/pmr.ts` has all domain types (Consultation, TimelineEntity, ConstellationNode, etc.)
|
||||
|
||||
### Key subsystems
|
||||
|
||||
| Subsystem | Entry point | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| Dashboard | `DashboardLayout.tsx` | Orchestrates tiles, constellation, timeline, detail panel |
|
||||
| Career Constellation | `CareerConstellation.tsx` | D3 force simulation; roles as clusters, skills as nodes; hover/click/tap/keyboard |
|
||||
| Detail Panel | `DetailPanelContext.tsx` + `DetailPanel.tsx` | Right-side slide-out; context-aware views per entity type |
|
||||
| Semantic Search | `lib/semantic-search.ts` + `lib/embedding-model.ts` | Pre-computed embeddings + local Xenova transformer model in browser |
|
||||
| Command Palette | `CommandPalette.tsx` | Ctrl+K; fuzzy (Fuse.js) + semantic search |
|
||||
| Chat Widget | `ChatWidget.tsx` + `lib/llm.ts` | Gemini/OpenRouter LLM integration; requires `.env` API keys |
|
||||
| Accessibility | `AccessibilityContext.tsx` | Focus management, reduced motion, ARIA |
|
||||
|
||||
### D3 integration pattern
|
||||
`CareerConstellation.tsx` manages D3 force simulation imperatively via refs. Highlight state tracked with refs (not React state) to avoid unnecessary re-renders. Touch: tap to pin, background tap to clear. Keyboard: Tab through nodes, Enter/Space activate, Escape reset.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TypeScript strict mode** — `noUnusedLocals`, `noUnusedParameters` enforced
|
||||
- **Path alias:** `@/*` → `src/*` (configured in vite.config.ts + tsconfig.json)
|
||||
- **Components:** PascalCase (`DashboardLayout.tsx`); Hooks: `useCamelCase`; Utilities: kebab-case (`semantic-search.ts`)
|
||||
- **Styling:** Tailwind utility classes + inline `CSSProperties` for dynamic/theme values
|
||||
- **Animations:** Framer Motion; respects `prefers-reduced-motion`
|
||||
- **Commits:** Conventional Commit prefixes (`feat:`, `chore:`, `fix:`) + optional story IDs
|
||||
|
||||
## Design tokens
|
||||
|
||||
- **Primary:** Teal `#00897B` / **Accent:** Coral `#FF6B6B`
|
||||
- **PMR palette:** GP system-inspired greens, teals, greys (defined in `tailwind.config.js`)
|
||||
- **Font tokens (CSS custom properties):**
|
||||
- `--font-ui`: Elvaro Grotesque (dashboard UI)
|
||||
- `--font-geist-mono`: Geist Mono / Fira Code fallback (canonical mono token)
|
||||
- `--font-primary` / `--font-secondary`: Plus Jakarta Sans / Inter Tight
|
||||
- **Breakpoints:** xs 480, sm 640, md 768, lg 1024, xl 1280
|
||||
@@ -1,87 +1,131 @@
|
||||
# Task: D3 Career Constellation Remediation (Hover, Timeline Parity, Visual Alignment)
|
||||
# Task: Career Constellation Chart & Layout Polish
|
||||
|
||||
Implement a full remediation of the career constellation chart and its linked timeline UI so interactions are reliable, timeline semantics are correct, and styling aligns with the rest of the site typography/tokens.
|
||||
|
||||
## Context
|
||||
|
||||
Recent chart refresh work did not fully resolve key issues:
|
||||
- Hover highlighting is still not consistently activating on chart nodes.
|
||||
- Timeline behavior in the chart is now more broken versus the work-experience timeline.
|
||||
- Styling in the chart layer is not fully aligned with the main design system (including font token consistency).
|
||||
|
||||
The implementation should be grounded in the current codebase and preserve existing UX intent where possible.
|
||||
Visual polish and layout adjustments to the career constellation chart, sidebar, and repeat medications section. 12 discrete changes across 10 files.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Fix hover interaction reliability in the D3 chart:
|
||||
- Ensure node hover consistently triggers graph highlighting on desktop.
|
||||
- Preserve touch behavior (tap-to-pin and clear interactions).
|
||||
- Preserve keyboard accessibility interactions.
|
||||
- Remove interaction-layer conflicts:
|
||||
- Resolve any pointer interception between invisible accessibility overlays and SVG node hit targets.
|
||||
- Ensure focus-only controls do not break pointer hover behavior.
|
||||
- Correct timeline data/semantic parity:
|
||||
- Ensure constellation role nodes map to the intended work-experience scope.
|
||||
- Prevent unintended education entries from being treated as role nodes unless explicitly intended.
|
||||
- Align ordering semantics between the chart timeline and work-experience timeline.
|
||||
- Stabilize highlight state behavior:
|
||||
- Ensure graph highlight state and linked timeline card highlighting remain coherent when hovering roles vs skills.
|
||||
- Avoid reset/flicker edge cases on mouseleave/blur transitions.
|
||||
- Align chart styling with site design system:
|
||||
- Use canonical font tokens consistently (UI vs mono usage should match the broader app).
|
||||
- Remove or replace invalid/undefined font token usage impacting timeline/chart-adjacent components.
|
||||
- Keep visual treatment consistent with existing dashboard cards/tokens (no unrelated redesign).
|
||||
- Keep architecture maintainable:
|
||||
- Clarify data exports for timeline consumers (career-only, education-only, combined) where needed.
|
||||
- Avoid duplicate or dead timeline component paths if they create inconsistency.
|
||||
### 1. Reduce link opacity (`src/components/constellation/constants.ts`)
|
||||
- Lower `LINK_BASE_OPACITY` from `0.08` → `0.04`
|
||||
- Lower `LINK_STRENGTH_OPACITY_FACTOR` from `0.12` → `0.06`
|
||||
- Makes skill connection lines subtler so job pills are visually clearer
|
||||
|
||||
## Validation Requirements
|
||||
### 2. White background on hovered job pill (`src/hooks/useConstellationHighlight.ts`)
|
||||
- When a role/education node is the `activeNodeId`, override its `.node-circle` fill to `#FFFFFF` with `fill-opacity: 1`
|
||||
- Currently uses a gradient fill with `fill-opacity: 0.25` — make it solid white, fully opaque
|
||||
|
||||
Run and pass:
|
||||
- `npm run lint`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
### 3. Move legend to top of chart + increase font size (`src/components/constellation/ConstellationLegend.tsx`)
|
||||
- Position legend as absolutely-positioned overlay at the **top** of the chart container (not below the SVG)
|
||||
- Increase font size from `10px` to `12px` to match work node label text size
|
||||
- Separate the "Hover to explore connections" text from the legend — see item 12
|
||||
|
||||
Also perform manual behavioral checks and record concise notes in `.ralph/review.md`:
|
||||
- Desktop hover on role nodes and skill nodes.
|
||||
- Cross-highlight behavior between chart and timeline cards.
|
||||
- Touch/coarse-pointer behavior (tap-to-pin and clear).
|
||||
- Keyboard focus navigation and activation behavior.
|
||||
- Timeline order parity sanity-check against work-experience content.
|
||||
### 4. Move year labels to right side of chart (`src/hooks/useForceSimulation.ts`)
|
||||
- Keep the current node layout unchanged (roles, skills, timeline line stay where they are)
|
||||
- Move year label text elements to the right edge of the chart: position at `width - sidePadding`, `text-anchor: 'end'`
|
||||
|
||||
## Likely Files In Scope
|
||||
### 5. Change chart fonts to dashboard style (`src/hooks/useForceSimulation.ts`)
|
||||
- Year labels: change `font-family` from `var(--font-geist-mono)` to `var(--font-ui)`
|
||||
- Year indicator (animation): same font change
|
||||
|
||||
- `src/components/CareerConstellation.tsx`
|
||||
- `src/components/DashboardLayout.tsx`
|
||||
- `src/components/TimelineInterventionsSubsection.tsx`
|
||||
- `src/components/WorkExperienceSubsection.tsx` (if retained, removed, or reintegrated)
|
||||
- `src/data/timeline.ts`
|
||||
- `src/data/constellation.ts`
|
||||
- `src/index.css`
|
||||
- Related types in `src/types/pmr.ts` if needed
|
||||
### 6. Reverse pathway column split to 40/60 (`src/index.css`)
|
||||
- Change `.pathway-columns` grid from `minmax(0, 1.3fr) minmax(0, 1fr)` to `minmax(0, 2fr) minmax(0, 3fr)`
|
||||
- This gives 40% to work experience text and 60% to the graph
|
||||
|
||||
### 7. Sidebar: collapses to icon rail when patient summary scrolls out of view (`src/components/Sidebar.tsx` + `src/components/DashboardLayout.tsx`)
|
||||
- Sidebar already starts expanded on desktop — no change needed there
|
||||
- Add IntersectionObserver on the PatientSummaryTile element in DashboardLayout
|
||||
- When PatientSummaryTile scrolls out of view, pass a `forceCollapsed` prop to Sidebar
|
||||
- Sidebar collapses to icon rail (same as current mobile rail behaviour with nav buttons + hamburger menu)
|
||||
- When PatientSummaryTile scrolls back into view, re-expand the sidebar
|
||||
- Only applies on desktop (≥1024px) — mobile behaviour unchanged
|
||||
|
||||
### 8. Change pathway stacking breakpoint from 1024px to 768px (`src/index.css`)
|
||||
- The `.pathway-columns` two-column layout currently triggers at `min-width: 1024px`
|
||||
- Change this to `min-width: 768px` so the graph sits beside text on tablets too
|
||||
- Sidebar breakpoint remains at 1024px (this only affects pathway columns)
|
||||
- Also update `.pathway-graph-sticky` responsive rule to match the `768px` breakpoint
|
||||
|
||||
### 9. Repeat medications: 3-column layout (`src/components/RepeatMedicationsSubsection.tsx`)
|
||||
- Render all 3 category sections (Technical, Healthcare Domain, Strategic & Leadership) side-by-side
|
||||
- Use CSS grid: `grid-template-columns: repeat(3, 1fr)` on `md` (768px+) screens
|
||||
- Stack vertically on mobile (<768px)
|
||||
- Remove the `marginTop` between categories when in grid mode (they'll be in columns)
|
||||
|
||||
### 10. Skills hover → chart highlight (verify only)
|
||||
- `RepeatMedicationsSubsection` already calls `onNodeHighlight` on hover
|
||||
- This flows through `DashboardLayout` → `highlightedNodeId` → `CareerConstellation` → `useConstellationHighlight`
|
||||
- Verify this interaction works end-to-end. If it does, no code change needed.
|
||||
|
||||
### 11. Play/pause button: left edge of chart, visible only when chart is in view (`src/components/constellation/PlayPauseButton.tsx` + `src/components/constellation/CareerConstellation.tsx`)
|
||||
- Move button to the far-left edge of the chart container (not bottom-right)
|
||||
- Use IntersectionObserver on the chart container to track if chart is visible
|
||||
- When chart is in viewport: show button at left edge, vertically centered
|
||||
- When chart scrolls out of view: hide the button
|
||||
- Increase base opacity from 0.6 to 0.85
|
||||
- Add slightly stronger border and subtle box-shadow for visibility
|
||||
|
||||
### 12. "Hover to explore connections" text — more visible, top-left above year indicator (`src/components/constellation/ConstellationLegend.tsx` or `src/components/constellation/CareerConstellation.tsx`)
|
||||
- Separate this text from the legend dot items
|
||||
- Position at the top-left of the chart, above the year indicator text
|
||||
- Increase opacity from 0.7 to 1
|
||||
- Increase font size (match or approach the legend font size)
|
||||
- On touch devices, show "Tap to explore connections" instead
|
||||
|
||||
## Success Criteria
|
||||
|
||||
All of the following must be true:
|
||||
- [ ] Constellation hover highlighting works reliably with pointer input.
|
||||
- [ ] Accessibility/focus affordances remain functional without breaking pointer interactions.
|
||||
- [ ] Timeline/role mapping in the chart is semantically correct and aligned with work-experience content.
|
||||
- [ ] Highlight synchronization between chart and timeline cards behaves predictably.
|
||||
- [ ] Font/token usage in chart and timeline-adjacent components is consistent with the app's design tokens.
|
||||
- [ ] Any legacy/duplicate timeline path that causes divergence is resolved or clearly justified.
|
||||
- [ ] `npm run lint` passes.
|
||||
- [ ] `npm run typecheck` passes.
|
||||
- [ ] `npm run build` passes.
|
||||
- [ ] Reviewer records manual verification outcomes in `.ralph/review.md`.
|
||||
- [ ] `npm run lint` passes with zero errors
|
||||
- [ ] `npm run typecheck` passes with zero errors
|
||||
- [ ] `npm run build` completes successfully
|
||||
- [ ] Link opacity constants lowered (LINK_BASE_OPACITY=0.04, LINK_STRENGTH_OPACITY_FACTOR=0.06)
|
||||
- [ ] Hovered role/education node gets white fill (#FFFFFF, fill-opacity 1)
|
||||
- [ ] Legend positioned at top of chart with 12px font size
|
||||
- [ ] Year labels positioned at right edge of chart with `var(--font-ui)` font
|
||||
- [ ] Pathway columns use 40/60 split (2fr/3fr)
|
||||
- [ ] Sidebar collapses to icon rail when patient summary scrolls out of view (desktop only)
|
||||
- [ ] Pathway columns go side-by-side at 768px (not 1024px)
|
||||
- [ ] Repeat medications renders 3 categories in grid columns on md+ screens
|
||||
- [ ] Play/pause button on left edge of chart, hidden when chart not in view
|
||||
- [ ] "Hover to explore" text at top-left of chart, full opacity, larger font
|
||||
|
||||
## Constraints
|
||||
|
||||
- Use the existing TypeScript + React + Vite stack and project conventions.
|
||||
- Keep changes scoped to constellation/timeline correctness and visual consistency.
|
||||
- Do not introduce broad unrelated refactors.
|
||||
- Prioritize correctness and maintainability over cosmetic novelty.
|
||||
- TypeScript strict mode — `noUnusedLocals`, `noUnusedParameters` enforced
|
||||
- Path alias: `@/*` → `src/*`
|
||||
- Styling: Tailwind utility classes + inline `CSSProperties` for dynamic/theme values
|
||||
- Animations: Framer Motion; respects `prefers-reduced-motion`
|
||||
- Design tokens: Primary teal `#00897B`, Accent coral `#FF6B6B`
|
||||
- Font tokens: `--font-ui` (Elvaro Grotesque), `--font-geist-mono` (Geist Mono)
|
||||
- Do not break existing hover/click/keyboard interactions on the constellation
|
||||
- Do not alter the D3 force simulation physics or node positioning logic (except year labels)
|
||||
- Preserve existing mobile behaviour unless explicitly changed (items 8, 9)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `src/components/constellation/constants.ts`
|
||||
2. `src/hooks/useConstellationHighlight.ts`
|
||||
3. `src/components/constellation/ConstellationLegend.tsx`
|
||||
4. `src/hooks/useForceSimulation.ts`
|
||||
5. `src/index.css`
|
||||
6. `src/components/Sidebar.tsx`
|
||||
7. `src/components/DashboardLayout.tsx`
|
||||
8. `src/components/RepeatMedicationsSubsection.tsx`
|
||||
9. `src/components/constellation/PlayPauseButton.tsx`
|
||||
10. `src/components/constellation/CareerConstellation.tsx`
|
||||
|
||||
## Status
|
||||
|
||||
Track progress in `.ralph/plan.md` and keep it updated.
|
||||
When all success criteria are met, print `LOOP_COMPLETE`.
|
||||
Track progress here. Mark items complete as you go.
|
||||
When all success criteria are met, print LOOP_COMPLETE.
|
||||
|
||||
- [ ] Item 1: Link opacity
|
||||
- [ ] Item 2: White hover pill
|
||||
- [ ] Item 3: Legend top position
|
||||
- [ ] Item 4: Year labels right
|
||||
- [ ] Item 5: Font change
|
||||
- [ ] Item 6: Column split 40/60
|
||||
- [ ] Item 7: Sidebar scroll collapse
|
||||
- [ ] Item 8: Stacking breakpoint 768px
|
||||
- [ ] Item 9: Medications 3-column
|
||||
- [ ] Item 10: Skills hover verify
|
||||
- [ ] Item 11: Play/pause button
|
||||
- [ ] Item 12: Hover text visibility
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
@@ -1,11 +1,11 @@
|
||||
cli:
|
||||
backend: "codex"
|
||||
backend: "claude"
|
||||
|
||||
event_loop:
|
||||
prompt_file: "PROMPT.md"
|
||||
starting_event: "work.start"
|
||||
completion_promise: "LOOP_COMPLETE"
|
||||
max_iterations: 35
|
||||
max_iterations: 50
|
||||
|
||||
backpressure:
|
||||
gates:
|
||||
@@ -22,60 +22,118 @@ backpressure:
|
||||
hats:
|
||||
planner:
|
||||
name: "Constellation Planner"
|
||||
description: "Plans chart interaction, timeline parity, and design-token alignment changes."
|
||||
description: "Analyses the codebase and writes a detailed implementation plan for the current phase."
|
||||
triggers: ["work.start", "review.changes_requested"]
|
||||
publishes: ["plan.ready"]
|
||||
memory:
|
||||
path: ".ralph/agent/memories.md"
|
||||
scope: "global"
|
||||
instructions: |
|
||||
Read PROMPT.md first.
|
||||
You are the Planner. Read PROMPT.md to understand the full task.
|
||||
|
||||
Your role is planning only:
|
||||
- Inspect chart pointer/focus interaction layers and hover event flow.
|
||||
- Inspect timeline data exports and role/education mapping used by constellation + timeline UI.
|
||||
- Write/update .ralph/plan.md with concrete file-level steps and acceptance checks.
|
||||
- Define clear scope boundaries so the builder avoids unrelated refactors.
|
||||
- If triggered by review.changes_requested, read .ralph/review.md and revise the plan.
|
||||
If triggered by review.changes_requested, read .ralph/review.md for feedback
|
||||
and update the plan to address the reviewer's concerns.
|
||||
|
||||
Do not implement code.
|
||||
Emit plan.ready when the plan is ready.
|
||||
Your job:
|
||||
1. Read PROMPT.md to understand the overall task and which phases remain
|
||||
2. Explore the current state of the codebase — check what's already been done
|
||||
by looking at PROMPT.md status checkboxes and the actual files
|
||||
3. Identify the NEXT incomplete phase to work on
|
||||
4. Write a detailed implementation plan to .ralph/plan.md with:
|
||||
- Which phase you're planning for
|
||||
- Specific files to create/modify (with full paths)
|
||||
- What each file should contain (key functions, exports, signatures)
|
||||
- Existing code/patterns to reuse (reference specific line ranges)
|
||||
- Potential pitfalls to avoid
|
||||
5. Emit plan.ready
|
||||
|
||||
IMPORTANT: Plan ONE phase at a time. Do not try to plan all 4 phases at once.
|
||||
Each plan should be focused and achievable in a single builder iteration.
|
||||
|
||||
Key files to reference:
|
||||
- src/components/CareerConstellation.tsx (the 1102-line monolith to decompose)
|
||||
- src/data/timeline.ts (temporal data, buildConstellationData)
|
||||
- src/data/skills.ts (skill definitions with startYear)
|
||||
- src/data/constellation.ts (data exports)
|
||||
- src/types/pmr.ts (type definitions)
|
||||
- src/components/DashboardLayout.tsx (integration point)
|
||||
- .claude/skills/d3-visualization/ (D3 patterns and examples)
|
||||
|
||||
Do NOT write any code. Planning only.
|
||||
|
||||
builder:
|
||||
name: "Constellation Builder"
|
||||
description: "Implements chart hover fixes, timeline/data alignment, and token-consistent styling."
|
||||
description: "Implements the current plan phase, writing clean code that passes all quality gates."
|
||||
triggers: ["plan.ready"]
|
||||
publishes: ["build.done"]
|
||||
memory:
|
||||
path: ".ralph/agent/memories.md"
|
||||
scope: "global"
|
||||
instructions: |
|
||||
Read PROMPT.md and .ralph/plan.md first.
|
||||
You are the Builder. Read PROMPT.md for the overall task and .ralph/plan.md
|
||||
for the current implementation plan.
|
||||
|
||||
Implement the planned work end-to-end:
|
||||
- Fix pointer hover reliability in the constellation chart.
|
||||
- Resolve interaction-layer conflicts between accessibility overlay controls and SVG node events.
|
||||
- Keep touch and keyboard flows working.
|
||||
- Align timeline mapping/order semantics between chart and work-experience surfaces.
|
||||
- Clarify timeline data exports (career/education/combined) where needed for correctness.
|
||||
- Align font/token usage with the site design system and remove invalid token usage.
|
||||
- Resolve or remove divergent/duplicate timeline pathing that causes inconsistent behavior.
|
||||
Your job:
|
||||
1. Read the plan carefully — understand what files to create/modify
|
||||
2. Implement the plan step by step
|
||||
3. After each significant change, run: npm run lint && npm run typecheck && npm run build
|
||||
4. Fix any lint/type/build errors immediately
|
||||
5. Update PROMPT.md status checkboxes as you complete items
|
||||
6. When the current phase's plan is fully implemented, emit build.done
|
||||
|
||||
Keep project conventions intact and avoid unrelated refactors.
|
||||
Update .ralph/plan.md as steps are completed.
|
||||
Emit build.done exactly once when implementation is complete and lint/typecheck/build pass.
|
||||
Code quality rules:
|
||||
- Follow existing patterns in the codebase (Tailwind, path aliases @/*, strict TS)
|
||||
- Prefer self-explanatory variable names over comments
|
||||
- Keep only active code — no dead code, no commented-out blocks
|
||||
- Reference .claude/skills/d3-visualization/ for D3 force layout patterns
|
||||
- Domain colors: clinical=#059669, technical=#0D6E6E, leadership=#D97706
|
||||
- Font tokens: --font-ui (Elvaro), --font-geist-mono (monospace)
|
||||
|
||||
IMPORTANT: When refactoring, preserve ALL existing behaviour — hover, click, tap,
|
||||
keyboard nav, mobile accordion, detail panel integration, reduced motion support.
|
||||
Verify imports resolve and the app compiles after every extraction.
|
||||
|
||||
Do NOT assess overall quality — that's the Reviewer's job.
|
||||
|
||||
reviewer:
|
||||
name: "Constellation Reviewer"
|
||||
description: "Validates behavior, parity, accessibility flows, and build quality against PROMPT requirements."
|
||||
description: "Validates the build against PROMPT.md success criteria and project quality standards."
|
||||
triggers: ["build.done"]
|
||||
publishes: ["review.changes_requested"]
|
||||
memory:
|
||||
path: ".ralph/agent/memories.md"
|
||||
scope: "global"
|
||||
instructions: |
|
||||
Read PROMPT.md (and .ralph/plan.md if needed), then review the final implementation.
|
||||
You are the Reviewer. Read PROMPT.md for the full success criteria.
|
||||
|
||||
Validate all success criteria:
|
||||
- Pointer hover reliability in chart nodes
|
||||
- No pointer/focus layering conflicts
|
||||
- Timeline parity between chart and work-experience surfaces
|
||||
- Coherent cross-highlighting behavior
|
||||
- Font/token consistency for chart and timeline-adjacent UI
|
||||
- Touch and keyboard behavior preserved
|
||||
- Lint/typecheck/build passing
|
||||
Your job:
|
||||
1. Run the quality gates: npm run lint && npm run typecheck && npm run build
|
||||
- All three MUST pass. If any fail, request changes immediately.
|
||||
2. Check PROMPT.md status — which phase was just completed?
|
||||
3. Review the code changes against the plan and success criteria:
|
||||
- Phase 1 (Refactor): Is the code well-structured? Orchestrator < 300 lines?
|
||||
All hooks and sub-components properly extracted? All existing behaviour preserved?
|
||||
- Phase 2 (Visual): Do links show domain colors and strength-weighted width?
|
||||
Are role/skill nodes visually enhanced? Entry animation present?
|
||||
- Phase 3 (Animation): Does it auto-play? Build chronologically from 2009?
|
||||
Include education entities? Loop continuously?
|
||||
- Phase 4 (Integration): Does hover/tap pause? Resume after 800ms?
|
||||
Play/pause button functional? Reduced motion handled?
|
||||
4. Check for regressions:
|
||||
- All CareerConstellation props still supported?
|
||||
- DashboardLayout integration intact?
|
||||
- Accessibility preserved (keyboard nav, screen reader, reduced motion)?
|
||||
- Import paths resolve correctly?
|
||||
- No TypeScript `any` types introduced?
|
||||
|
||||
Write findings and manual verification notes to .ralph/review.md.
|
||||
If anything is incomplete or incorrect, emit review.changes_requested with specific fixes.
|
||||
If all criteria pass, print LOOP_COMPLETE and stop.
|
||||
If ALL success criteria for the completed phase are met AND quality gates pass:
|
||||
- If more phases remain, write feedback to .ralph/review.md noting the phase
|
||||
is done, then emit review.changes_requested so the Planner plans the next phase.
|
||||
- If ALL four phases are complete and ALL success criteria met,
|
||||
write final review to .ralph/review.md and print LOOP_COMPLETE.
|
||||
|
||||
If changes are needed, write specific actionable feedback to .ralph/review.md
|
||||
referencing file paths. Emit review.changes_requested.
|
||||
|
||||
Circuit breaker: If the same blocker repeats across 2+ consecutive reviews
|
||||
with no meaningful progress, escalate in .ralph/review.md with status "needs-human".
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
cli:
|
||||
backend: "codex"
|
||||
backend: "claude"
|
||||
|
||||
event_loop:
|
||||
prompt_file: "PROMPT.md"
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [phase, setPhase] = useState<Phase>('login')
|
||||
const [phase, setPhase] = useState<Phase>('boot')
|
||||
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -69,7 +69,7 @@ const COLORS = {
|
||||
}
|
||||
|
||||
const BOOT_CONFIG: BootConfig = {
|
||||
header: 'CLINICAL TERMINAL v3.2.1',
|
||||
header: 'CV Management Information System v1.0.0',
|
||||
lines: [
|
||||
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
|
||||
{ type: 'separator', text: '---', style: 'dim' },
|
||||
@@ -88,7 +88,7 @@ const BOOT_CONFIG: BootConfig = {
|
||||
timing: {
|
||||
lineDelay: 220,
|
||||
cursorBlinkInterval: 300,
|
||||
holdAfterComplete: 900,
|
||||
holdAfterComplete: 1000,
|
||||
fadeOutDuration: 600,
|
||||
cursorShrinkDuration: 600,
|
||||
ecgStartDelay: 0,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import { CardHeader } from './Card'
|
||||
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
|
||||
import { ProjectsTile } from './tiles/ProjectsTile'
|
||||
import { ParentSection } from './ParentSection'
|
||||
import CareerConstellation from './CareerConstellation'
|
||||
import CareerConstellation from './constellation/CareerConstellation'
|
||||
import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsection'
|
||||
import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
|
||||
import { ChatWidget } from './ChatWidget'
|
||||
@@ -128,7 +128,7 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '20px',
|
||||
marginBottom: '14px',
|
||||
marginBottom: '1=px',
|
||||
paddingBottom: '14px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
cursor: 'pointer',
|
||||
@@ -182,7 +182,7 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '7px',
|
||||
marginBottom: '16px',
|
||||
marginBottom: '0px',
|
||||
}}
|
||||
>
|
||||
{consultation.examination.map((bullet, index) => (
|
||||
@@ -250,7 +250,9 @@ export function DashboardLayout() {
|
||||
const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null)
|
||||
const [highlightedRoleId, setHighlightedRoleId] = useState<string | null>(null)
|
||||
const [chronologyHeight, setChronologyHeight] = useState<number | null>(null)
|
||||
const [constellationReady, setConstellationReady] = useState(false)
|
||||
const chronologyRef = useRef<HTMLDivElement>(null)
|
||||
const patientSummaryRef = useRef<HTMLDivElement>(null)
|
||||
const activeSection = useActiveSection()
|
||||
const { openPanel } = useDetailPanel()
|
||||
const careerConsultationsById = useMemo(
|
||||
@@ -258,6 +260,20 @@ export function DashboardLayout() {
|
||||
[],
|
||||
)
|
||||
|
||||
// Signal constellation animation readiness when patient summary scrolls out of view
|
||||
useEffect(() => {
|
||||
const el = patientSummaryRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting) setConstellationReady(true)
|
||||
},
|
||||
{ threshold: 0 },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Measure the chronology stream height so the constellation graph can match it
|
||||
useEffect(() => {
|
||||
const el = chronologyRef.current
|
||||
@@ -427,7 +443,9 @@ export function DashboardLayout() {
|
||||
>
|
||||
<div className="dashboard-grid">
|
||||
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
|
||||
<PatientSummaryTile />
|
||||
<div ref={patientSummaryRef}>
|
||||
<PatientSummaryTile />
|
||||
</div>
|
||||
|
||||
{/* ProjectsTile — full width */}
|
||||
<ProjectsTile />
|
||||
@@ -436,31 +454,7 @@ export function DashboardLayout() {
|
||||
<ParentSection title="Patient Pathway" tileId="patient-pathway">
|
||||
<div className="pathway-columns">
|
||||
<div ref={chronologyRef} className="chronology-stream" data-tile-id="section-experience">
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '14px',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--bg-dashboard)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: '4px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
}}
|
||||
>
|
||||
Clinical Record Stream
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
|
||||
Chronological role and education entries. Select items to inspect full records.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="chronology-item">
|
||||
<LastConsultationSubsection highlightedRoleId={highlightedRoleId} />
|
||||
@@ -477,6 +471,7 @@ export function DashboardLayout() {
|
||||
onNodeHover={handleNodeHover}
|
||||
highlightedNodeId={highlightedNodeId}
|
||||
containerHeight={chronologyHeight}
|
||||
animationReady={constellationReady}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const HOLD_SECONDS = 2 // Hold after text completes, before flatline/transition
|
||||
const FLATLINE_DRAW_SECONDS = 0.3 // Time to draw flatline
|
||||
const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out
|
||||
const BG_TRANSITION_SECONDS = 0.2 // Background color transition
|
||||
const SKIP_TEXT = true // Skip text phase — transition directly after heartbeats
|
||||
|
||||
// =============================================================================
|
||||
// Letter Definitions (ECG waveform shapes for each letter)
|
||||
@@ -344,7 +345,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
||||
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
|
||||
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
|
||||
const textEndWX = textStartWX + totalTextW
|
||||
const textEndWX = SKIP_TEXT ? textStartWX : textStartWX + totalTextW
|
||||
const textLayout = layoutText(
|
||||
textStartWX, LETTER_W, LETTER_G, SPACE_W,
|
||||
baselineY, 0, Infinity
|
||||
@@ -354,7 +355,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED
|
||||
const holdEndTime = textEndTime
|
||||
const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS
|
||||
const fadeStartTime = flatlineEndTime + HOLD_SECONDS
|
||||
const fadeStartTime = flatlineEndTime + (SKIP_TEXT ? 0.3 : HOLD_SECONDS)
|
||||
const fadeEndTime = fadeStartTime + FADE_TO_BLACK_SECONDS
|
||||
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
|
||||
const exitEndTime = bgTransitionEndTime
|
||||
@@ -500,7 +501,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
const isTextPhase = headWX > textStartWX
|
||||
const isTextDone = elapsed >= textEndTime
|
||||
|
||||
if (isTextPhase) {
|
||||
if (isTextPhase && !SKIP_TEXT) {
|
||||
ctx.save()
|
||||
|
||||
// Clip for progressive reveal
|
||||
|
||||
@@ -268,18 +268,20 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
|
||||
title="REPEAT MEDICATIONS"
|
||||
rightText="Active prescriptions"
|
||||
/>
|
||||
{groupedSkills.map((group, index) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
label={group.label}
|
||||
categoryId={group.id}
|
||||
skills={group.skills}
|
||||
onSkillClick={handleSkillClick}
|
||||
onViewAll={handleViewAll}
|
||||
isFirst={index === 0}
|
||||
onNodeHighlight={onNodeHighlight}
|
||||
/>
|
||||
))}
|
||||
<div className="medications-grid">
|
||||
{groupedSkills.map((group) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
label={group.label}
|
||||
categoryId={group.id}
|
||||
skills={group.skills}
|
||||
onSkillClick={handleSkillClick}
|
||||
onViewAll={handleViewAll}
|
||||
isFirst
|
||||
onNodeHighlight={onNodeHighlight}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ function TimelineInterventionItem({
|
||||
onHighlight,
|
||||
}: TimelineInterventionItemProps) {
|
||||
const isEducation = entity.kind === 'education'
|
||||
const interventionLabel = isEducation ? 'Education Intervention' : 'Career Intervention'
|
||||
const interventionLabel = isEducation ? 'Education' : 'Employment'
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -76,9 +76,9 @@ function TimelineInterventionItem({
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
padding: '12px 14px',
|
||||
padding: '8px 8px',
|
||||
cursor: 'pointer',
|
||||
minHeight: '44px',
|
||||
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
@@ -113,15 +113,13 @@ function TimelineInterventionItem({
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginBottom: '6px',
|
||||
|
||||
}}
|
||||
>
|
||||
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
|
||||
{interventionLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
@@ -131,7 +129,8 @@ function TimelineInterventionItem({
|
||||
>
|
||||
{entity.title}
|
||||
</div>
|
||||
<div
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
@@ -139,17 +138,23 @@ function TimelineInterventionItem({
|
||||
}}
|
||||
>
|
||||
{entity.organization}
|
||||
</div>
|
||||
<div
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
paddingLeft: '6px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '3px',
|
||||
}}
|
||||
>
|
||||
{entity.dateRange.display}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<ChevronRight
|
||||
@@ -160,6 +165,7 @@ function TimelineInterventionItem({
|
||||
marginTop: '2px',
|
||||
transform: isExpanded ? 'rotate(90deg)' : 'none',
|
||||
transition: 'transform 0.15s ease-out',
|
||||
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import type { ConstellationNode } from '@/types/pmr'
|
||||
import { ROLE_WIDTH, ROLE_HEIGHT, MOBILE_ROLE_WIDTH } from './constants'
|
||||
|
||||
interface AccessibleNodeOverlayProps {
|
||||
nodes: ConstellationNode[]
|
||||
nodeButtonPositions: Record<string, { x: number; y: number }>
|
||||
dimensions: { width: number; height: number; scaleFactor: number }
|
||||
onFocus: (nodeId: string) => void
|
||||
onBlur: () => void
|
||||
onClick: (nodeId: string, nodeType: 'role' | 'skill' | 'education') => void
|
||||
onKeyDown: (e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill' | 'education') => void
|
||||
}
|
||||
|
||||
export const AccessibleNodeOverlay: React.FC<AccessibleNodeOverlayProps> = ({
|
||||
nodes,
|
||||
nodeButtonPositions,
|
||||
dimensions,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const domainOrder: Record<string, number> = { technical: 0, clinical: 1, leadership: 2 }
|
||||
const isEntity = (t: string) => t === 'role' || t === 'education'
|
||||
const sorted = [...nodes].sort((a, b) => {
|
||||
if (isEntity(a.type) && !isEntity(b.type)) return -1
|
||||
if (!isEntity(a.type) && isEntity(b.type)) return 1
|
||||
if (isEntity(a.type) && isEntity(b.type)) {
|
||||
return (b.startYear ?? 0) - (a.startYear ?? 0)
|
||||
}
|
||||
const da = domainOrder[a.domain ?? 'technical'] ?? 0
|
||||
const db = domainOrder[b.domain ?? 'technical'] ?? 0
|
||||
if (da !== db) return da - db
|
||||
return (a.label ?? '').localeCompare(b.label ?? '')
|
||||
})
|
||||
|
||||
const isMobileBtn = typeof window !== 'undefined' && window.innerWidth < 640
|
||||
const btnSf = isMobileBtn ? 1 : dimensions.scaleFactor
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Career nodes - use Tab to navigate and Enter to open details"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{sorted.map(node => {
|
||||
const yearRange = node.endYear
|
||||
? `${node.startYear}-${node.endYear}`
|
||||
: `${node.startYear}-present`
|
||||
|
||||
const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
|
||||
const isEntityBtn = isEntity(node.type)
|
||||
const buttonWidth = isEntityBtn ? (isMobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf)
|
||||
const buttonHeight = isEntityBtn ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
aria-label={
|
||||
isEntityBtn
|
||||
? `${node.label} at ${node.organization}, ${yearRange}. Press Enter to view details.`
|
||||
: `${node.label} skill node. Press Enter to view details.`
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: buttonWidth,
|
||||
height: buttonHeight,
|
||||
top: `${position.y}px`,
|
||||
left: `${position.x}px`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
padding: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
onFocus={() => onFocus(node.id)}
|
||||
onBlur={onBlur}
|
||||
onClick={() => onClick(node.id, node.type)}
|
||||
onKeyDown={e => onKeyDown(e, node.id, node.type)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { constellationNodes } from '@/data/constellation'
|
||||
import { timelineEntities } from '@/data/timeline'
|
||||
import { useForceSimulation, getHeight } from '@/hooks/useForceSimulation'
|
||||
import { useConstellationHighlight } from '@/hooks/useConstellationHighlight'
|
||||
import { useConstellationInteraction } from '@/hooks/useConstellationInteraction'
|
||||
import { useTimelineAnimation } from '@/hooks/useTimelineAnimation'
|
||||
import { MobileAccordion } from './MobileAccordion'
|
||||
import { ConstellationLegend } from './ConstellationLegend'
|
||||
import { AccessibleNodeOverlay } from './AccessibleNodeOverlay'
|
||||
import { PlayPauseButton } from './PlayPauseButton'
|
||||
import { srDescription } from './screen-reader-description'
|
||||
import {
|
||||
MIN_HEIGHT,
|
||||
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
||||
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
||||
supportsCoarsePointer, prefersReducedMotion,
|
||||
} from './constants'
|
||||
|
||||
interface CareerConstellationProps {
|
||||
onRoleClick: (id: string) => void
|
||||
onSkillClick: (id: string) => void
|
||||
onNodeHover?: (id: string | null) => void
|
||||
highlightedNodeId?: string | null
|
||||
containerHeight?: number | null
|
||||
animationReady?: boolean
|
||||
}
|
||||
|
||||
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
|
||||
const careerEntityById = new Map(timelineEntities.map(entity => [entity.id, entity]))
|
||||
|
||||
const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
onRoleClick,
|
||||
onSkillClick,
|
||||
onNodeHover,
|
||||
highlightedNodeId,
|
||||
containerHeight,
|
||||
animationReady = false,
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover })
|
||||
const highlightedNodeIdRef = useRef<string | null>(highlightedNodeId ?? null)
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
|
||||
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
|
||||
const [chartInView, setChartInView] = useState(true)
|
||||
|
||||
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
|
||||
|
||||
useEffect(() => {
|
||||
highlightedNodeIdRef.current = highlightedNodeId ?? null
|
||||
}, [highlightedNodeId])
|
||||
|
||||
// Track chart visibility for play/pause button
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setChartInView(entry.isIntersecting),
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const CHANGE_THRESHOLD = 0.3
|
||||
|
||||
const updateDimensions = () => {
|
||||
const width = container.clientWidth
|
||||
const viewportWidth = window.innerWidth
|
||||
const height = getHeight(viewportWidth, containerHeight)
|
||||
const scaleFactor = viewportWidth >= 1024
|
||||
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
||||
: 1
|
||||
setDimensions(prev => {
|
||||
const widthDelta = Math.abs(prev.width - width) / prev.width
|
||||
const heightDelta = Math.abs(prev.height - height) / prev.height
|
||||
if (widthDelta < CHANGE_THRESHOLD && heightDelta < CHANGE_THRESHOLD) {
|
||||
return prev
|
||||
}
|
||||
return { width, height, scaleFactor }
|
||||
})
|
||||
}
|
||||
|
||||
// Initial measurement (no debounce)
|
||||
updateDimensions()
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(updateDimensions, 2000)
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
}
|
||||
}, [containerHeight])
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
||||
const sf = isMobile ? 1 : dimensions.scaleFactor
|
||||
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
|
||||
const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf)
|
||||
|
||||
const resolveGraphFallback = useCallback(
|
||||
() => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current,
|
||||
[],
|
||||
)
|
||||
|
||||
const resolveRoleFallback = useCallback(() => {
|
||||
const hId = highlightedNodeIdRef.current
|
||||
const hType = hId ? nodeById.get(hId)?.type : null
|
||||
if (hId && hType && hType !== 'skill') return hId
|
||||
const pId = pinnedNodeIdRef.current
|
||||
const pType = pId ? nodeById.get(pId)?.type : null
|
||||
if (pId && pType && pType !== 'skill') return pId
|
||||
return null
|
||||
}, [])
|
||||
|
||||
// Shared refs for hooks
|
||||
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
|
||||
const nodesRef = useRef<import('./types').SimNode[]>([])
|
||||
const nodeSelectionRef = useRef<d3.Selection<SVGGElement, import('./types').SimNode, SVGGElement, unknown> | null>(null)
|
||||
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, import('./types').SimLink, SVGGElement, unknown> | null>(null)
|
||||
const connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
|
||||
const skillRestRadiiRef = useRef<Map<string, number>>(new Map())
|
||||
const visibleNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const { applyGraphHighlight } = useConstellationHighlight({
|
||||
nodeSelectionRef,
|
||||
linkSelectionRef,
|
||||
connectedMap: connectedMapRef.current,
|
||||
srDefault,
|
||||
srActive,
|
||||
nodesRef,
|
||||
skillRestRadii: skillRestRadiiRef.current,
|
||||
visibleNodeIdsRef,
|
||||
})
|
||||
|
||||
highlightGraphRef.current = applyGraphHighlight
|
||||
|
||||
const simOptionsRef = useRef({
|
||||
resolveGraphFallback,
|
||||
applyHighlight: applyGraphHighlight,
|
||||
})
|
||||
simOptionsRef.current = { resolveGraphFallback, applyHighlight: applyGraphHighlight }
|
||||
|
||||
const stableSimOptions = useMemo(() => ({
|
||||
resolveGraphFallback: () => simOptionsRef.current.resolveGraphFallback(),
|
||||
applyHighlight: (id: string | null) => simOptionsRef.current.applyHighlight(id),
|
||||
}), [])
|
||||
|
||||
const sim = useForceSimulation(svgRef, dimensions, stableSimOptions)
|
||||
|
||||
// Sync simulation refs
|
||||
useEffect(() => {
|
||||
nodesRef.current = sim.nodesRef.current
|
||||
nodeSelectionRef.current = sim.nodeSelectionRef.current
|
||||
linkSelectionRef.current = sim.linkSelectionRef.current
|
||||
if (sim.connectedMap.size > 0) connectedMapRef.current = sim.connectedMap
|
||||
if (sim.skillRestRadii.size > 0) skillRestRadiiRef.current = sim.skillRestRadii
|
||||
})
|
||||
|
||||
// Animation hook
|
||||
const animation = useTimelineAnimation({
|
||||
nodeSelectionRef,
|
||||
linkSelectionRef,
|
||||
simulationRef: sim.simulationRef,
|
||||
yearIndicatorRef: sim.yearIndicatorRef,
|
||||
connectorSelectionRef: sim.connectorSelectionRef,
|
||||
timelineGroupRef: sim.timelineGroupRef,
|
||||
skillRestRadiiRef,
|
||||
srDefault,
|
||||
dimensionsTrigger: dimensions.width + dimensions.height,
|
||||
ready: animationReady,
|
||||
})
|
||||
|
||||
// Sync visibleNodeIdsRef from animation hook
|
||||
visibleNodeIdsRef.current = animation.visibleNodeIdsRef.current
|
||||
|
||||
// Interaction hook
|
||||
const { pinnedNodeId, setPinnedNodeId, pinnedNodeIdRef } = useConstellationInteraction({
|
||||
highlightGraphRef,
|
||||
nodeSelectionRef,
|
||||
svgRef,
|
||||
callbacksRef,
|
||||
resolveGraphFallback,
|
||||
resolveRoleFallback,
|
||||
dimensionsTrigger: dimensions.width + dimensions.height,
|
||||
pauseForInteraction: animation.pauseForInteraction,
|
||||
resumeAfterInteraction: animation.resumeAfterInteraction,
|
||||
})
|
||||
|
||||
// External highlight sync
|
||||
useEffect(() => {
|
||||
if (!highlightGraphRef.current) return
|
||||
highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId)
|
||||
}, [highlightedNodeId, pinnedNodeId])
|
||||
|
||||
// Focus ring management
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) return
|
||||
const svg = d3.select(svgRef.current)
|
||||
svg.selectAll('.focus-ring').attr('stroke', 'transparent')
|
||||
if (focusedNodeId) {
|
||||
svg.selectAll<SVGGElement, { id: string }>('g.node')
|
||||
.filter(d => d.id === focusedNodeId)
|
||||
.select('.focus-ring')
|
||||
.attr('stroke', 'var(--accent)')
|
||||
.attr('stroke-width', 2)
|
||||
}
|
||||
}, [focusedNodeId])
|
||||
|
||||
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill' | 'education') => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
setPinnedNodeId(nodeId)
|
||||
pinnedNodeIdRef.current = nodeId
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
onNodeHover?.(nodeType !== 'skill' ? nodeId : resolveRoleFallback())
|
||||
;(nodeType !== 'skill' ? onRoleClick : onSkillClick)(nodeId)
|
||||
}, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback, setPinnedNodeId, pinnedNodeIdRef])
|
||||
|
||||
const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && (n.type === 'role' || n.type === 'education')) : null
|
||||
const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) ?? null : null
|
||||
const domainCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
constellationNodes.filter(n => n.type === 'skill').forEach(n => {
|
||||
const d = n.domain ?? 'technical'
|
||||
counts[d] = (counts[d] ?? 0) + 1
|
||||
})
|
||||
return counts
|
||||
}, [])
|
||||
|
||||
const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-light)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
role="img"
|
||||
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: dimensions.height,
|
||||
opacity: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
|
||||
|
||||
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
|
||||
|
||||
{!prefersReducedMotion && (
|
||||
<PlayPauseButton
|
||||
isPlaying={animation.isPlaying}
|
||||
onToggle={animation.togglePlayPause}
|
||||
isMobile={isMobile}
|
||||
visible={chartInView}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1, height: 1, padding: 0, margin: -1,
|
||||
overflow: 'hidden', clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap', border: 0,
|
||||
}}
|
||||
>
|
||||
{srDescription}
|
||||
</p>
|
||||
|
||||
<AccessibleNodeOverlay
|
||||
nodes={constellationNodes}
|
||||
nodeButtonPositions={sim.nodeButtonPositions}
|
||||
dimensions={dimensions}
|
||||
onFocus={(nodeId) => {
|
||||
setFocusedNodeId(nodeId)
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
const node = nodeById.get(nodeId)
|
||||
if (node?.type !== 'skill') onNodeHover?.(nodeId)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocusedNodeId(null)
|
||||
highlightGraphRef.current?.(resolveGraphFallback())
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
}}
|
||||
onClick={(nodeId, nodeType) => {
|
||||
setPinnedNodeId(nodeId)
|
||||
pinnedNodeIdRef.current = nodeId
|
||||
highlightGraphRef.current?.(nodeId)
|
||||
if (nodeType !== 'skill') {
|
||||
onNodeHover?.(nodeId)
|
||||
onRoleClick(nodeId)
|
||||
} else {
|
||||
onNodeHover?.(resolveRoleFallback())
|
||||
onSkillClick(nodeId)
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleNodeKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CareerConstellation
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import { supportsCoarsePointer } from './constants'
|
||||
|
||||
interface ConstellationLegendProps {
|
||||
isTouch: boolean
|
||||
domainCounts?: Record<string, number>
|
||||
}
|
||||
|
||||
export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch, domainCounts }) => {
|
||||
const items = [
|
||||
{ label: 'Technical', domain: 'technical', color: 'var(--accent)' },
|
||||
{ label: 'Clinical', domain: 'clinical', color: 'var(--success)' },
|
||||
{ label: 'Leadership', domain: 'leadership', color: 'var(--amber)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '8px 12px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
color: 'var(--text-secondary)',
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
{isTouch || supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-tertiary)',
|
||||
lineHeight: '24px',
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={item.label}>
|
||||
{i > 0 && (
|
||||
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
|
||||
)}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: item.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{item.label}{domainCounts?.[item.domain] != null ? ` (${domainCounts[item.domain]})` : ''}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import type { TimelineEntity } from '@/types/pmr'
|
||||
import { prefersReducedMotion } from './constants'
|
||||
|
||||
interface MobileAccordionProps {
|
||||
pinnedCareerEntity: TimelineEntity | null
|
||||
show: boolean
|
||||
}
|
||||
|
||||
export const MobileAccordion: React.FC<MobileAccordionProps> = ({ pinnedCareerEntity, show }) => {
|
||||
const [accordionShowMore, setAccordionShowMore] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setAccordionShowMore(false)
|
||||
}, [pinnedCareerEntity?.id])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && pinnedCareerEntity && (
|
||||
<motion.div
|
||||
key={pinnedCareerEntity.id}
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: `1px solid ${pinnedCareerEntity.orgColor ?? 'var(--border-light)'}`,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '2px' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{pinnedCareerEntity.title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-secondary)',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
paddingLeft: '14px',
|
||||
}}
|
||||
>
|
||||
{pinnedCareerEntity.organization} · {pinnedCareerEntity.dateRange.display}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul style={{ margin: 0, paddingLeft: '14px', listStyle: 'none' }}>
|
||||
{(accordionShowMore ? pinnedCareerEntity.details : pinnedCareerEntity.details.slice(0, 3)).map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: '1.5',
|
||||
marginBottom: '4px',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
|
||||
opacity: 0.5,
|
||||
flexShrink: 0,
|
||||
marginTop: '7px',
|
||||
}}
|
||||
/>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{accordionShowMore && (pinnedCareerEntity.outcomes ?? []).length > 0 && (
|
||||
<ul style={{ margin: '8px 0 0', paddingLeft: '14px', listStyle: 'none' }}>
|
||||
{(pinnedCareerEntity.outcomes ?? []).map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-tertiary)',
|
||||
lineHeight: '1.5',
|
||||
marginBottom: '4px',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--text-tertiary)',
|
||||
opacity: 0.4,
|
||||
flexShrink: 0,
|
||||
marginTop: '7px',
|
||||
}}
|
||||
/>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pinnedCareerEntity.details.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccordionShowMore(prev => !prev)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 14px',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: pinnedCareerEntity.orgColor ?? 'var(--accent)',
|
||||
fontWeight: 500,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{accordionShowMore ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface PlayPauseButtonProps {
|
||||
isPlaying: boolean
|
||||
onToggle: () => void
|
||||
isMobile: boolean
|
||||
visible?: boolean
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({
|
||||
isPlaying, onToggle, isMobile, visible = true, containerRef,
|
||||
}) => {
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
|
||||
const scale = vw >= 1440 ? 1.75 : vw >= 1280 ? 1.5 : vw >= 1080 ? 1.25 : 1
|
||||
const size = isMobile ? 44 : Math.round(36 * scale)
|
||||
const offset = isMobile ? 8 : Math.round(12 * scale)
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const [topPos, setTopPos] = useState(56)
|
||||
const [scrolling, setScrolling] = useState(false)
|
||||
const debounceRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const scrollParent = container.closest('.dashboard-main') as HTMLElement | null
|
||||
if (!scrollParent) return
|
||||
|
||||
const margin = isMobile ? 12 : 56
|
||||
|
||||
const update = () => {
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const sRect = scrollParent.getBoundingClientRect()
|
||||
const visibleTop = Math.max(sRect.top, cRect.top) + margin + 50
|
||||
const visibleBottom = Math.min(sRect.bottom, cRect.bottom) - size - 12
|
||||
const targetY = Math.min(visibleTop, visibleBottom)
|
||||
const relativeTop = targetY - cRect.top
|
||||
setTopPos(Math.max(margin, relativeTop))
|
||||
|
||||
setScrolling(true)
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = window.setTimeout(() => setScrolling(false), 1000)
|
||||
}
|
||||
|
||||
scrollParent.addEventListener('scroll', update, { passive: true })
|
||||
window.addEventListener('resize', update, { passive: true })
|
||||
update()
|
||||
// Don't start hidden — clear the initial scroll trigger
|
||||
setScrolling(false)
|
||||
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [containerRef, isMobile, size])
|
||||
|
||||
const showButton = visible && !scrolling
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={onToggle}
|
||||
aria-label={isPlaying ? 'Pause animation' : 'Play animation'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: offset,
|
||||
top: topPos,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
boxShadow: '0 1px 4px rgba(26,43,42,0.10)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: showButton ? 0.85 : 0,
|
||||
pointerEvents: showButton ? 'auto' : 'none',
|
||||
transition: scrolling
|
||||
? 'opacity 150ms ease, top 80ms linear'
|
||||
: 'opacity 500ms ease, top 80ms linear',
|
||||
zIndex: 5,
|
||||
}}
|
||||
onMouseEnter={e => { if (showButton) e.currentTarget.style.opacity = '1' }}
|
||||
onMouseLeave={e => { if (showButton) e.currentTarget.style.opacity = '0.85' }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 14 14" fill="var(--text-secondary)">
|
||||
<rect x="2" y="1" width="4" height="12" rx="1" />
|
||||
<rect x="8" y="1" width="4" height="12" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 14 14" fill="var(--text-secondary)">
|
||||
<polygon points="3,1 13,7 3,13" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Sizing
|
||||
export const MIN_HEIGHT = 400
|
||||
export const MOBILE_FALLBACK_HEIGHT = 520
|
||||
export const ROLE_WIDTH = 104
|
||||
export const ROLE_HEIGHT = 32
|
||||
export const ROLE_RX = 16
|
||||
export const SKILL_RADIUS_DEFAULT = 7
|
||||
export const SKILL_RADIUS_ACTIVE = 11
|
||||
export const MOBILE_ROLE_WIDTH = 80
|
||||
export const MOBILE_SKILL_RADIUS_DEFAULT = 6
|
||||
export const MOBILE_SKILL_RADIUS_ACTIVE = 9
|
||||
export const MOBILE_LABEL_MAX_LEN = 10
|
||||
|
||||
// Animation / opacity
|
||||
export const HIGHLIGHT_DIM_OPACITY = 0.15
|
||||
export const SKILL_REST_OPACITY = 0.6
|
||||
export const SKILL_ACTIVE_OPACITY = 0.9
|
||||
export const LABEL_REST_OPACITY = 0.6
|
||||
|
||||
// Link visual params
|
||||
export const LINK_BASE_WIDTH = 0.7
|
||||
export const LINK_STRENGTH_WIDTH_FACTOR = 0
|
||||
export const LINK_BASE_OPACITY = 0
|
||||
export const LINK_STRENGTH_OPACITY_FACTOR = 0
|
||||
export const LINK_HIGHLIGHT_BASE_WIDTH = 1
|
||||
export const LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR = 2
|
||||
export const LINK_BEZIER_VERTICAL_OFFSET = 0.15
|
||||
|
||||
// Role node visual params
|
||||
export const ROLE_STROKE_OPACITY_DEFAULT = 1
|
||||
export const ROLE_STROKE_OPACITY_ACTIVE = 1
|
||||
export const ROLE_STROKE_OPACITY_CONNECTED = 0.9
|
||||
export const ROLE_STROKE_WIDTH_DEFAULT = 1
|
||||
export const ROLE_STROKE_WIDTH_ACTIVE = 2
|
||||
export const ROLE_FILL_OPACITY_ACTIVE = 1
|
||||
export const ROLE_FILL_ACTIVE = '#FFFFFF'
|
||||
|
||||
// Skill node visual params
|
||||
export const SKILL_STROKE_WIDTH = 1
|
||||
export const SKILL_STROKE_OPACITY = 0.4
|
||||
export const SKILL_SIZE_ROLE_FACTOR = 0.8
|
||||
export const SKILL_GLOW_STD_DEVIATION = 2.5
|
||||
export const SKILL_ACTIVE_STROKE_OPACITY = 0.1
|
||||
|
||||
// Skill overlap offsets
|
||||
export const SKILL_Y_OFFSET_STEP = 25
|
||||
export const SKILL_Y_OFFSET_STEP_MOBILE = 20
|
||||
export const SKILL_Y_GLOBAL_OFFSET_RATIO = -0.05
|
||||
export const SKILL_X_OVERLAP_MAX_RATIO = 1
|
||||
// Entry animation
|
||||
export const ENTRY_GUIDE_FADE_MS = 200
|
||||
export const ENTRY_ROLE_STAGGER_MS = 80
|
||||
export const ENTRY_ROLE_DURATION_MS = 300
|
||||
export const ENTRY_SKILL_STAGGER_MS = 30
|
||||
export const ENTRY_SKILL_DURATION_MS = 250
|
||||
|
||||
// Timeline animation
|
||||
export const ANIM_CHRONOLOGICAL_ENABLED = true
|
||||
export const ANIM_ENTITY_REVEAL_MS = 2000
|
||||
export const ANIM_SKILL_REVEAL_MS = 2000
|
||||
export const ANIM_SKILL_STAGGER_MS = 200
|
||||
export const ANIM_LINK_DRAW_MS = 600
|
||||
export const ANIM_LINK_STAGGER_MS = 200
|
||||
export const ANIM_REINFORCEMENT_MS = 700
|
||||
export const ANIM_STEP_GAP_MS = 1000
|
||||
export const ANIM_HOLD_MS = 15000
|
||||
export const ANIM_RESET_MS = 800
|
||||
export const ANIM_RESTART_DELAY_MS = 400
|
||||
export const ANIM_INTERACTION_RESUME_MS = 800
|
||||
export const ANIM_SETTLE_ALPHA = 0.05
|
||||
export const ANIM_MONTH_STEP_MS = 80
|
||||
|
||||
// Domain color map
|
||||
export const DOMAIN_COLOR_MAP: Record<string, string> = {
|
||||
clinical: '#059669',
|
||||
technical: '#0D6E6E',
|
||||
leadership: '#D97706',
|
||||
}
|
||||
|
||||
// Entities hidden from the constellation (education + early career roles)
|
||||
export const HIDDEN_ENTITY_IDS = new Set([
|
||||
'pre-reg-pharmacist-2015',
|
||||
'duty-pharmacy-manager-2016',
|
||||
'uea-mpharm-2011',
|
||||
'highworth-alevels-2009',
|
||||
])
|
||||
|
||||
// Media queries (evaluated once at module level)
|
||||
export const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
export const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
|
||||
@@ -0,0 +1,25 @@
|
||||
import { constellationNodes, roleSkillMappings } from '@/data/constellation'
|
||||
|
||||
function buildScreenReaderDescription(): string {
|
||||
const entities = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')
|
||||
const skills = constellationNodes.filter(n => n.type === 'skill')
|
||||
|
||||
const entityDescriptions = entities.map(entity => {
|
||||
const mapping = roleSkillMappings.find(m => m.roleId === entity.id)
|
||||
const skillNames = mapping
|
||||
? mapping.skillIds
|
||||
.map(sid => skills.find(s => s.id === sid)?.label)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: ''
|
||||
const yearRange = entity.endYear
|
||||
? `${entity.startYear}-${entity.endYear}`
|
||||
: `${entity.startYear}-present`
|
||||
return `${entity.label} at ${entity.organization} (${yearRange}): ${skillNames}`
|
||||
})
|
||||
|
||||
return `Career constellation graph showing ${entities.length} roles and ${skills.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` +
|
||||
entityDescriptions.join('. ') + '.'
|
||||
}
|
||||
|
||||
export const srDescription = buildScreenReaderDescription()
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ConstellationNode } from '@/types/pmr'
|
||||
|
||||
export interface SimNode extends ConstellationNode {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
homeX: number
|
||||
homeY: number
|
||||
}
|
||||
|
||||
export interface SimLink {
|
||||
source: SimNode | string
|
||||
target: SimNode | string
|
||||
strength: number
|
||||
}
|
||||
|
||||
export interface LayoutParams {
|
||||
width: number
|
||||
height: number
|
||||
scaleFactor: number
|
||||
isMobile: boolean
|
||||
rw: number
|
||||
rh: number
|
||||
rrx: number
|
||||
srDefault: number
|
||||
srActive: number
|
||||
topPadding: number
|
||||
bottomPadding: number
|
||||
sidePadding: number
|
||||
timelineX: number
|
||||
sf: number
|
||||
}
|
||||
|
||||
export interface ConstellationCallbacks {
|
||||
onRoleClick: (id: string) => void
|
||||
onSkillClick: (id: string) => void
|
||||
onNodeHover?: (id: string | null) => void
|
||||
}
|
||||
|
||||
export type AnimationState = 'IDLE' | 'PLAYING' | 'PAUSED' | 'HOLDING' | 'RESETTING'
|
||||
|
||||
export interface AnimationStep {
|
||||
entityId: string
|
||||
startYear: number
|
||||
startMonth: number // 0-indexed (0 = January)
|
||||
skillIds: string[]
|
||||
newSkillIds: string[]
|
||||
reinforcedSkillIds: string[]
|
||||
linkPairs: Array<{ source: string; target: string }>
|
||||
}
|
||||
@@ -447,18 +447,19 @@ export function buildConstellationData(): {
|
||||
constellationNodes: ConstellationNode[]
|
||||
constellationLinks: ConstellationLink[]
|
||||
} {
|
||||
const roleSkillMappings: RoleSkillMapping[] = timelineCareerEntities.map((entity) => ({
|
||||
const roleSkillMappings: RoleSkillMapping[] = timelineEntities.map((entity) => ({
|
||||
roleId: entity.id,
|
||||
skillIds: entity.skills,
|
||||
}))
|
||||
|
||||
const roleNodes: ConstellationNode[] = timelineCareerEntities.map((entity) => ({
|
||||
const roleNodes: ConstellationNode[] = timelineEntities.map((entity) => ({
|
||||
id: entity.id,
|
||||
type: 'role',
|
||||
type: entity.kind === 'education' ? 'education' as const : 'role' as const,
|
||||
label: entity.title,
|
||||
shortLabel: entity.graphLabel,
|
||||
organization: entity.organization,
|
||||
startYear: entity.dateRange.startYear,
|
||||
startDate: entity.dateRange.start,
|
||||
endYear: entity.dateRange.endYear,
|
||||
orgColor: entity.orgColor,
|
||||
}))
|
||||
@@ -471,7 +472,7 @@ export function buildConstellationData(): {
|
||||
domain: skillDomainByCategory[skill.category],
|
||||
}))
|
||||
|
||||
const constellationLinks: ConstellationLink[] = timelineCareerEntities.flatMap((entity) =>
|
||||
const constellationLinks: ConstellationLink[] = timelineEntities.flatMap((entity) =>
|
||||
entity.skills.map((skillId) => ({
|
||||
source: entity.id,
|
||||
target: skillId,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import type * as d3 from 'd3'
|
||||
import { select as d3select } from 'd3'
|
||||
import {
|
||||
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
||||
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
||||
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
||||
LINK_HIGHLIGHT_BASE_WIDTH, LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR,
|
||||
SKILL_STROKE_OPACITY, SKILL_ACTIVE_STROKE_OPACITY,
|
||||
SKILL_REST_OPACITY, SKILL_ACTIVE_OPACITY, LABEL_REST_OPACITY,
|
||||
HIGHLIGHT_DIM_OPACITY,
|
||||
ROLE_STROKE_OPACITY_DEFAULT, ROLE_STROKE_OPACITY_ACTIVE, ROLE_STROKE_OPACITY_CONNECTED,
|
||||
ROLE_STROKE_WIDTH_DEFAULT, ROLE_STROKE_WIDTH_ACTIVE,
|
||||
ROLE_FILL_OPACITY_ACTIVE, ROLE_FILL_ACTIVE,
|
||||
} from '@/components/constellation/constants'
|
||||
import type { SimNode, SimLink } from '@/components/constellation/types'
|
||||
|
||||
function getSkillDomainColor(link: SimLink, nodes: SimNode[]): string {
|
||||
const tgtId = typeof link.target === 'string' ? link.target : (link.target as SimNode).id
|
||||
const srcId = typeof link.source === 'string' ? link.source : (link.source as SimNode).id
|
||||
const skillId = nodes.find(n => n.id === tgtId)?.type === 'skill' ? tgtId : srcId
|
||||
const skillNode = nodes.find(n => n.id === skillId)
|
||||
return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
|
||||
}
|
||||
|
||||
function resolveLinkId(end: SimNode | string): string {
|
||||
return typeof end === 'string' ? end : end.id
|
||||
}
|
||||
|
||||
export function useConstellationHighlight(deps: {
|
||||
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
|
||||
linkSelectionRef: React.MutableRefObject<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null>
|
||||
connectedMap: Map<string, Set<string>>
|
||||
srDefault: number
|
||||
srActive: number
|
||||
nodesRef: React.MutableRefObject<SimNode[]>
|
||||
skillRestRadii?: Map<string, number>
|
||||
visibleNodeIdsRef?: React.MutableRefObject<Set<string>>
|
||||
}) {
|
||||
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
|
||||
|
||||
const applyGraphHighlight = useCallback((activeNodeId: string | null) => {
|
||||
const nodeSelection = deps.nodeSelectionRef.current
|
||||
const linkSelection = deps.linkSelectionRef.current
|
||||
if (!nodeSelection || !linkSelection) return
|
||||
|
||||
const { srDefault, srActive, connectedMap, skillRestRadii } = deps
|
||||
const nodes = deps.nodesRef.current
|
||||
const dur = prefersReducedMotion ? 0 : 180
|
||||
const visibleIds = deps.visibleNodeIdsRef?.current
|
||||
const isVisible = (id: string) => !visibleIds || visibleIds.has(id)
|
||||
|
||||
if (!activeNodeId) {
|
||||
// Reset — respect animation visibility
|
||||
nodeSelection.style('opacity', d => isVisible(d.id) ? '1' : '0')
|
||||
|
||||
nodeSelection.filter(d => d.type !== 'skill')
|
||||
.attr('filter', null)
|
||||
.select('.node-circle')
|
||||
.each(function () {
|
||||
const el = d3select(this)
|
||||
el.attr('fill', el.attr('data-base-fill'))
|
||||
})
|
||||
.attr('fill-opacity', null)
|
||||
.attr('stroke-opacity', ROLE_STROKE_OPACITY_DEFAULT)
|
||||
.attr('stroke-width', ROLE_STROKE_WIDTH_DEFAULT)
|
||||
|
||||
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
||||
if (dur > 0) {
|
||||
skillNodes.select('.node-circle')
|
||||
.transition().duration(dur)
|
||||
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
|
||||
.attr('fill-opacity', SKILL_REST_OPACITY)
|
||||
.attr('filter', null)
|
||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||
skillNodes.select('.node-label')
|
||||
.transition().duration(dur)
|
||||
.attr('opacity', LABEL_REST_OPACITY)
|
||||
} else {
|
||||
skillNodes.select('.node-circle')
|
||||
.attr('r', d => isVisible(d.id) ? getRestRadius(d) : 0)
|
||||
.attr('fill-opacity', SKILL_REST_OPACITY)
|
||||
.attr('filter', null)
|
||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||
skillNodes.select('.node-label')
|
||||
.attr('opacity', LABEL_REST_OPACITY)
|
||||
}
|
||||
|
||||
linkSelection
|
||||
.attr('stroke', l => getSkillDomainColor(l, nodes))
|
||||
.attr('stroke-width', l => LINK_BASE_WIDTH + l.strength * LINK_STRENGTH_WIDTH_FACTOR)
|
||||
.attr('stroke-opacity', l => {
|
||||
const src = resolveLinkId(l.source)
|
||||
const tgt = resolveLinkId(l.target)
|
||||
if (!isVisible(src) || !isVisible(tgt)) return 0
|
||||
return LINK_BASE_OPACITY + l.strength * LINK_STRENGTH_OPACITY_FACTOR
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const connected = connectedMap.get(activeNodeId) ?? new Set()
|
||||
const isInGroup = (id: string) => id === activeNodeId || connected.has(id)
|
||||
|
||||
nodeSelection.style('opacity', d => {
|
||||
if (!isVisible(d.id)) return '0'
|
||||
return isInGroup(d.id) ? '1' : String(HIGHLIGHT_DIM_OPACITY)
|
||||
})
|
||||
|
||||
nodeSelection.filter(d => d.type !== 'skill')
|
||||
.attr('filter', d => {
|
||||
if (d.id === activeNodeId) return 'url(#shadow-md-filter)'
|
||||
if (connected.has(d.id)) return 'url(#shadow-sm-filter)'
|
||||
return null
|
||||
})
|
||||
.select('.node-circle')
|
||||
.each(function (d) {
|
||||
const el = d3select(this)
|
||||
el.attr('fill', d.id === activeNodeId ? ROLE_FILL_ACTIVE : el.attr('data-base-fill'))
|
||||
})
|
||||
.attr('fill-opacity', d => d.id === activeNodeId ? ROLE_FILL_OPACITY_ACTIVE : null)
|
||||
.attr('stroke-opacity', d => {
|
||||
if (d.id === activeNodeId) return ROLE_STROKE_OPACITY_ACTIVE
|
||||
if (connected.has(d.id)) return ROLE_STROKE_OPACITY_CONNECTED
|
||||
return ROLE_STROKE_OPACITY_DEFAULT
|
||||
})
|
||||
.attr('stroke-width', d => d.id === activeNodeId ? ROLE_STROKE_WIDTH_ACTIVE : ROLE_STROKE_WIDTH_DEFAULT)
|
||||
|
||||
const skillNodes = nodeSelection.filter(d => d.type === 'skill')
|
||||
const getRestRadius = (d: SimNode) => skillRestRadii?.get(d.id) ?? srDefault
|
||||
const getActiveRadius = (d: SimNode) => {
|
||||
const roleCount = (skillRestRadii?.get(d.id) ?? srDefault) - srDefault
|
||||
return srActive + roleCount
|
||||
}
|
||||
if (dur > 0) {
|
||||
skillNodes.select('.node-circle')
|
||||
.transition().duration(dur)
|
||||
.attr('r', d => {
|
||||
if (!isVisible(d.id)) return 0
|
||||
return isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d)
|
||||
})
|
||||
.attr('fill-opacity', d => isInGroup(d.id) ? SKILL_ACTIVE_OPACITY : SKILL_REST_OPACITY)
|
||||
.attr('filter', d => isInGroup(d.id) ? `url(#glow-${d.domain ?? 'technical'})` : null)
|
||||
.attr('stroke-opacity', d => isInGroup(d.id) ? SKILL_ACTIVE_STROKE_OPACITY : SKILL_STROKE_OPACITY)
|
||||
skillNodes.select('.node-label')
|
||||
.transition().duration(dur)
|
||||
.attr('opacity', d => isInGroup(d.id) ? 1 : LABEL_REST_OPACITY)
|
||||
} else {
|
||||
skillNodes.select('.node-circle')
|
||||
.attr('r', d => {
|
||||
if (!isVisible(d.id)) return 0
|
||||
return isInGroup(d.id) ? getActiveRadius(d) : getRestRadius(d)
|
||||
})
|
||||
.attr('fill-opacity', d => isInGroup(d.id) ? SKILL_ACTIVE_OPACITY : SKILL_REST_OPACITY)
|
||||
.attr('filter', d => isInGroup(d.id) ? `url(#glow-${d.domain ?? 'technical'})` : null)
|
||||
.attr('stroke-opacity', d => isInGroup(d.id) ? SKILL_ACTIVE_STROKE_OPACITY : SKILL_STROKE_OPACITY)
|
||||
skillNodes.select('.node-label')
|
||||
.attr('opacity', d => isInGroup(d.id) ? 1 : LABEL_REST_OPACITY)
|
||||
}
|
||||
|
||||
linkSelection
|
||||
.attr('stroke', l => {
|
||||
const src = resolveLinkId(l.source)
|
||||
const tgt = resolveLinkId(l.target)
|
||||
if (src === activeNodeId || tgt === activeNodeId) {
|
||||
const skillId = src === activeNodeId ? tgt : src
|
||||
const skillNode = nodes.find(n => n.id === skillId)
|
||||
return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
|
||||
}
|
||||
return getSkillDomainColor(l, nodes)
|
||||
})
|
||||
.attr('stroke-opacity', l => {
|
||||
const src = resolveLinkId(l.source)
|
||||
const tgt = resolveLinkId(l.target)
|
||||
if (!isVisible(src) || !isVisible(tgt)) return 0
|
||||
if (src === activeNodeId || tgt === activeNodeId) {
|
||||
return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2))
|
||||
}
|
||||
return LINK_BASE_OPACITY + l.strength * LINK_STRENGTH_OPACITY_FACTOR
|
||||
})
|
||||
.attr('stroke-width', l => {
|
||||
const src = resolveLinkId(l.source)
|
||||
const tgt = resolveLinkId(l.target)
|
||||
if (src === activeNodeId || tgt === activeNodeId) {
|
||||
return LINK_HIGHLIGHT_BASE_WIDTH + l.strength * LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR
|
||||
}
|
||||
return LINK_BASE_WIDTH + l.strength * LINK_STRENGTH_WIDTH_FACTOR
|
||||
})
|
||||
}, [deps])
|
||||
|
||||
highlightGraphRef.current = applyGraphHighlight
|
||||
|
||||
return {
|
||||
highlightGraphRef,
|
||||
applyGraphHighlight,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { supportsCoarsePointer } from '@/components/constellation/constants'
|
||||
import type { SimNode, ConstellationCallbacks } from '@/components/constellation/types'
|
||||
|
||||
export function useConstellationInteraction(deps: {
|
||||
highlightGraphRef: React.MutableRefObject<((id: string | null) => void) | null>
|
||||
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
|
||||
svgRef: React.RefObject<SVGSVGElement | null>
|
||||
callbacksRef: React.MutableRefObject<ConstellationCallbacks>
|
||||
resolveGraphFallback: () => string | null
|
||||
resolveRoleFallback: () => string | null
|
||||
dimensionsTrigger: number
|
||||
pauseForInteraction?: () => void
|
||||
resumeAfterInteraction?: () => void
|
||||
}) {
|
||||
const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null)
|
||||
const pinnedNodeIdRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
pinnedNodeIdRef.current = pinnedNodeId
|
||||
}, [pinnedNodeId])
|
||||
|
||||
const bindEvents = useCallback(() => {
|
||||
const nodeSelection = deps.nodeSelectionRef.current
|
||||
const svgEl = deps.svgRef.current
|
||||
if (!nodeSelection || !svgEl) return
|
||||
|
||||
const svg = d3.select(svgEl)
|
||||
|
||||
svg.select('.bg-rect').on('click.interaction', () => {
|
||||
if (supportsCoarsePointer) {
|
||||
setPinnedNodeId(null)
|
||||
pinnedNodeIdRef.current = null
|
||||
deps.highlightGraphRef.current?.(null)
|
||||
deps.callbacksRef.current.onNodeHover?.(null)
|
||||
deps.resumeAfterInteraction?.()
|
||||
}
|
||||
})
|
||||
|
||||
nodeSelection.on('mouseenter.interaction', function(_event: MouseEvent, d: SimNode) {
|
||||
if (supportsCoarsePointer) return
|
||||
deps.pauseForInteraction?.()
|
||||
deps.highlightGraphRef.current?.(d.id)
|
||||
if (d.type !== 'skill') {
|
||||
deps.callbacksRef.current.onNodeHover?.(d.id)
|
||||
}
|
||||
})
|
||||
|
||||
nodeSelection.on('mouseleave.interaction', function() {
|
||||
if (supportsCoarsePointer) return
|
||||
deps.highlightGraphRef.current?.(deps.resolveGraphFallback())
|
||||
deps.callbacksRef.current.onNodeHover?.(deps.resolveRoleFallback())
|
||||
deps.resumeAfterInteraction?.()
|
||||
})
|
||||
|
||||
nodeSelection.on('click.interaction', function(_event: MouseEvent, d: SimNode) {
|
||||
if (supportsCoarsePointer) {
|
||||
if (pinnedNodeIdRef.current === d.id) {
|
||||
setPinnedNodeId(null)
|
||||
pinnedNodeIdRef.current = null
|
||||
deps.highlightGraphRef.current?.(null)
|
||||
deps.callbacksRef.current.onNodeHover?.(null)
|
||||
deps.resumeAfterInteraction?.()
|
||||
} else {
|
||||
setPinnedNodeId(d.id)
|
||||
pinnedNodeIdRef.current = d.id
|
||||
deps.pauseForInteraction?.()
|
||||
deps.highlightGraphRef.current?.(d.id)
|
||||
deps.callbacksRef.current.onNodeHover?.(d.type !== 'skill' ? d.id : deps.resolveRoleFallback())
|
||||
}
|
||||
}
|
||||
|
||||
if (d.type !== 'skill') {
|
||||
deps.callbacksRef.current.onRoleClick(d.id)
|
||||
} else {
|
||||
deps.callbacksRef.current.onSkillClick(d.id)
|
||||
}
|
||||
})
|
||||
}, [deps])
|
||||
|
||||
useEffect(() => {
|
||||
bindEvents()
|
||||
}, [deps.dimensionsTrigger, bindEvents])
|
||||
|
||||
return {
|
||||
pinnedNodeId,
|
||||
setPinnedNodeId,
|
||||
pinnedNodeIdRef,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { constellationNodes, constellationLinks } from '@/data/constellation'
|
||||
import {
|
||||
ROLE_WIDTH, ROLE_HEIGHT, ROLE_RX,
|
||||
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
||||
MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN,
|
||||
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
||||
DOMAIN_COLOR_MAP, HIDDEN_ENTITY_IDS, prefersReducedMotion,
|
||||
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
||||
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
||||
LINK_BEZIER_VERTICAL_OFFSET,
|
||||
SKILL_STROKE_WIDTH, SKILL_STROKE_OPACITY, SKILL_SIZE_ROLE_FACTOR,
|
||||
SKILL_GLOW_STD_DEVIATION,
|
||||
SKILL_Y_OFFSET_STEP, SKILL_Y_OFFSET_STEP_MOBILE,
|
||||
SKILL_Y_GLOBAL_OFFSET_RATIO, SKILL_X_OVERLAP_MAX_RATIO,
|
||||
} from '@/components/constellation/constants'
|
||||
import type { SimNode, SimLink, LayoutParams } from '@/components/constellation/types'
|
||||
|
||||
function hashString(input: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i)
|
||||
hash |= 0
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
function isEntityNode(type: string): boolean {
|
||||
return type === 'role' || type === 'education'
|
||||
}
|
||||
|
||||
function fractionalYear(node: { startDate?: string; startYear?: number }): number {
|
||||
if (node.startDate) {
|
||||
const d = new Date(node.startDate)
|
||||
const year = d.getFullYear()
|
||||
const start = new Date(year, 0, 1).getTime()
|
||||
const end = new Date(year + 1, 0, 1).getTime()
|
||||
return year + (d.getTime() - start) / (end - start)
|
||||
}
|
||||
return node.startYear ?? 2016
|
||||
}
|
||||
|
||||
function getHeight(width: number, containerHeight?: number | null): number {
|
||||
if (width < 768) return 520
|
||||
if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight)
|
||||
return 400
|
||||
}
|
||||
|
||||
const roleNodes = constellationNodes.filter(n => (n.type === 'role' || n.type === 'education') && !HIDDEN_ENTITY_IDS.has(n.id))
|
||||
|
||||
export function useForceSimulation(
|
||||
svgRef: React.RefObject<SVGSVGElement | null>,
|
||||
dimensions: { width: number; height: number; scaleFactor: number },
|
||||
options: {
|
||||
resolveGraphFallback: () => string | null
|
||||
applyHighlight: (activeNodeId: string | null) => void
|
||||
}
|
||||
) {
|
||||
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
|
||||
const nodesRef = useRef<SimNode[]>([])
|
||||
const nodeSelectionRef = useRef<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>(null)
|
||||
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null>(null)
|
||||
const connectorSelectionRef = useRef<d3.Selection<SVGLineElement, SimNode, SVGGElement, unknown> | null>(null)
|
||||
const yearIndicatorRef = useRef<d3.Selection<SVGTextElement, unknown, null, undefined> | null>(null)
|
||||
const timelineGroupRef = useRef<d3.Selection<SVGGElement, unknown, null, undefined> | null>(null)
|
||||
const connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
|
||||
const skillRestRadiiRef = useRef<Map<string, number>>(new Map())
|
||||
const layoutParamsRef = useRef<LayoutParams | null>(null)
|
||||
const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const svg = d3.select(svgRef.current)
|
||||
if (!svgRef.current) return
|
||||
|
||||
const { width, height, scaleFactor } = dimensions
|
||||
const isMobile = window.innerWidth < 640
|
||||
const sf = isMobile ? 1 : scaleFactor
|
||||
|
||||
if (simulationRef.current) {
|
||||
simulationRef.current.stop()
|
||||
}
|
||||
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const years = roleNodes.map(n => fractionalYear(n))
|
||||
const now = new Date()
|
||||
const currentFractionalYear = now.getFullYear() + now.getMonth() / 12
|
||||
const minYear = Math.min(...years)
|
||||
const maxYear = Math.max(...years, currentFractionalYear)
|
||||
|
||||
const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf)
|
||||
const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf)
|
||||
const rrx = isMobile ? ROLE_RX : Math.round(ROLE_RX * sf)
|
||||
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
|
||||
const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf)
|
||||
|
||||
const topPadding = isMobile ? 36 : Math.round(46 * sf)
|
||||
const bottomPadding = isMobile ? 40 : Math.round(46 * sf)
|
||||
const sidePadding = isMobile ? 20 : Math.round(36 * sf)
|
||||
const timelineX = isMobile
|
||||
? Math.max(60, width * 0.16)
|
||||
: Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18))
|
||||
|
||||
const layoutParams: LayoutParams = {
|
||||
width, height, scaleFactor, isMobile,
|
||||
rw, rh, rrx, srDefault, srActive,
|
||||
topPadding, bottomPadding, sidePadding, timelineX, sf,
|
||||
}
|
||||
layoutParamsRef.current = layoutParams
|
||||
|
||||
// Power scale gives more space to recent (dense) years, compresses older ones
|
||||
const yearSpan = maxYear - minYear
|
||||
const rawScale = d3.scalePow().exponent(0.5)
|
||||
.domain([0, yearSpan])
|
||||
.range([topPadding, height - bottomPadding])
|
||||
const yScale = (year: number) => rawScale(maxYear - year)
|
||||
|
||||
// Background rect
|
||||
svg.append('rect')
|
||||
.attr('class', 'bg-rect')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('fill', 'var(--surface)')
|
||||
.attr('rx', 6)
|
||||
|
||||
// SVG filter defs
|
||||
const defs = svg.append('defs')
|
||||
|
||||
const shadowSm = defs.append('filter')
|
||||
.attr('id', 'shadow-sm-filter')
|
||||
.attr('x', '-20%').attr('y', '-20%')
|
||||
.attr('width', '140%').attr('height', '140%')
|
||||
shadowSm.append('feDropShadow')
|
||||
.attr('dx', 0).attr('dy', 1)
|
||||
.attr('stdDeviation', 1.5)
|
||||
.attr('flood-color', 'rgba(26,43,42,0.08)')
|
||||
|
||||
const shadowMd = defs.append('filter')
|
||||
.attr('id', 'shadow-md-filter')
|
||||
.attr('x', '-30%').attr('y', '-30%')
|
||||
.attr('width', '160%').attr('height', '160%')
|
||||
shadowMd.append('feDropShadow')
|
||||
.attr('dx', 0).attr('dy', 2)
|
||||
.attr('stdDeviation', 3)
|
||||
.attr('flood-color', 'rgba(26,43,42,0.12)')
|
||||
|
||||
// Glow filters per domain
|
||||
Object.entries(DOMAIN_COLOR_MAP).forEach(([domain]) => {
|
||||
const glow = defs.append('filter')
|
||||
.attr('id', `glow-${domain}`)
|
||||
.attr('x', '-50%').attr('y', '-50%')
|
||||
.attr('width', '200%').attr('height', '200%')
|
||||
glow.append('feGaussianBlur')
|
||||
.attr('in', 'SourceGraphic')
|
||||
.attr('stdDeviation', SKILL_GLOW_STD_DEVIATION)
|
||||
.attr('result', 'blur')
|
||||
const merge = glow.append('feMerge')
|
||||
merge.append('feMergeNode').attr('in', 'blur')
|
||||
merge.append('feMergeNode').attr('in', 'SourceGraphic')
|
||||
})
|
||||
|
||||
// Role gradient defs
|
||||
const uniqueOrgColors = [...new Set(constellationNodes.filter(n => isEntityNode(n.type)).map(n => n.orgColor ?? 'var(--accent)'))]
|
||||
uniqueOrgColors.forEach((color, i) => {
|
||||
const grad = defs.append('linearGradient')
|
||||
.attr('id', `role-grad-${i}`)
|
||||
.attr('x1', '0%').attr('y1', '0%')
|
||||
.attr('x2', '100%').attr('y2', '0%')
|
||||
grad.append('stop').attr('offset', '0%').attr('stop-color', color).attr('stop-opacity', 0.15)
|
||||
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0.3)
|
||||
})
|
||||
const orgColorGradientMap = new Map(uniqueOrgColors.map((c, i) => [c, `url(#role-grad-${i})`]))
|
||||
|
||||
// Date indicator group (for animation) — month + year with clip mask for scroll effect
|
||||
const dateFontSize = isMobile ? 18 : Math.round(24 * sf)
|
||||
const dateX = width * 0.1
|
||||
const dateY = topPadding - 4
|
||||
const lineHeight = Math.round(dateFontSize * 1.3)
|
||||
const clipId = 'date-indicator-clip'
|
||||
|
||||
const dateClip = defs.append('clipPath').attr('id', clipId)
|
||||
dateClip.append('rect')
|
||||
.attr('x', dateX - 4)
|
||||
.attr('y', dateY - dateFontSize - 2)
|
||||
.attr('width', isMobile ? 120 : Math.round(160 * sf))
|
||||
.attr('height', lineHeight + 4)
|
||||
|
||||
const dateGroup = svg.append('g')
|
||||
.attr('class', 'date-indicator')
|
||||
.attr('clip-path', `url(#${clipId})`)
|
||||
.attr('opacity', 0)
|
||||
|
||||
dateGroup.append('text')
|
||||
.attr('class', 'date-month')
|
||||
.attr('x', dateX)
|
||||
.attr('y', dateY)
|
||||
.attr('font-size', dateFontSize)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('font-weight', 500)
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.attr('letter-spacing', '0.08em')
|
||||
|
||||
dateGroup.append('text')
|
||||
.attr('class', 'date-year')
|
||||
.attr('x', dateX + (isMobile ? 52 : Math.round(68 * sf)))
|
||||
.attr('y', dateY)
|
||||
.attr('font-size', dateFontSize)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('font-weight', 300)
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.attr('opacity', 0.6)
|
||||
|
||||
yearIndicatorRef.current = dateGroup as unknown as d3.Selection<SVGTextElement, unknown, null, undefined>
|
||||
|
||||
// Timeline guides
|
||||
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
|
||||
timelineGroupRef.current = timelineGroup as unknown as d3.Selection<SVGGElement, unknown, null, undefined>
|
||||
|
||||
const tickYears = d3.range(Math.ceil(minYear), Math.floor(maxYear) + 1)
|
||||
timelineGroup.selectAll('line.year-guide')
|
||||
.data(tickYears)
|
||||
.join('line')
|
||||
.attr('class', 'year-guide')
|
||||
.attr('data-year', d => d)
|
||||
.attr('x1', sidePadding)
|
||||
.attr('x2', width - sidePadding)
|
||||
.attr('y1', d => yScale(d))
|
||||
.attr('y2', d => yScale(d))
|
||||
.attr('stroke', 'var(--border-light)')
|
||||
.attr('stroke-opacity', 0.25)
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', '3 4')
|
||||
|
||||
const labelSpace = isMobile ? 26 : Math.round(28 * sf)
|
||||
const axisRightPadding = isMobile ? 16 : Math.round(12 * sf)
|
||||
const axisX = width - axisRightPadding - labelSpace
|
||||
|
||||
const topTickY = tickYears.length > 0 ? yScale(tickYears[0]) : topPadding
|
||||
timelineGroup.append('line')
|
||||
.attr('class', 'axis-line')
|
||||
.attr('x1', axisX)
|
||||
.attr('x2', axisX)
|
||||
.attr('y1', topTickY - 12)
|
||||
.attr('y2', height - bottomPadding + 12)
|
||||
.attr('stroke', 'var(--border)')
|
||||
.attr('stroke-width', 1)
|
||||
|
||||
timelineGroup.selectAll('line.year-tick')
|
||||
.data(tickYears)
|
||||
.join('line')
|
||||
.attr('class', 'year-tick')
|
||||
.attr('data-year', d => d)
|
||||
.attr('x1', axisX)
|
||||
.attr('x2', d => axisX - (roleNodes.some(r => r.startYear === d) ? 8 : 6))
|
||||
.attr('y1', d => yScale(d))
|
||||
.attr('y2', d => yScale(d))
|
||||
.attr('stroke', 'var(--border)')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-opacity', 1)
|
||||
|
||||
timelineGroup.selectAll('text.year-label')
|
||||
.data(tickYears)
|
||||
.join('text')
|
||||
.attr('class', 'year-label')
|
||||
.attr('data-year', d => d)
|
||||
.attr('x', axisX + 8)
|
||||
.attr('y', d => yScale(d) + Math.round(4 * sf))
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
||||
.attr('font-family', 'var(--font-ui)')
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.attr('opacity', 1)
|
||||
.text(d => d)
|
||||
|
||||
// Prepare data — filter out hidden entities and their exclusive links/skills
|
||||
const visibleLinks = constellationLinks.filter(l => !HIDDEN_ENTITY_IDS.has(l.source))
|
||||
const visibleSkillIds = new Set(visibleLinks.map(l => l.target))
|
||||
const visibleNodeData = constellationNodes.filter(n =>
|
||||
HIDDEN_ENTITY_IDS.has(n.id) ? false : (isEntityNode(n.type) || visibleSkillIds.has(n.id))
|
||||
)
|
||||
const links: SimLink[] = visibleLinks.map(l => ({
|
||||
source: l.source,
|
||||
target: l.target,
|
||||
strength: l.strength,
|
||||
}))
|
||||
|
||||
const roleOrder = [...roleNodes].sort((a, b) => fractionalYear(a) - fractionalYear(b))
|
||||
const roleInitialMap = new Map<string, { x: number; y: number }>()
|
||||
const roleGap = isMobile ? 54 : Math.round(54 * sf)
|
||||
const roleX = axisX - roleGap - rw / 2
|
||||
|
||||
roleOrder.forEach((role) => {
|
||||
roleInitialMap.set(role.id, {
|
||||
x: roleX,
|
||||
y: yScale(fractionalYear(role)),
|
||||
})
|
||||
})
|
||||
|
||||
// Skills occupy the left ~65% of the chart
|
||||
const skillZoneRight = roleX - rw / 2 - (isMobile ? 16 : Math.round(24 * sf))
|
||||
const skillZoneLeft = sidePadding + srActive
|
||||
const skillZoneWidth = skillZoneRight - skillZoneLeft
|
||||
|
||||
// Pre-compute skill homeY and group by role-set to offset overlaps
|
||||
const skillRoleKey = new Map<string, string>() // skillId -> sorted role key
|
||||
const skillBaseY = new Map<string, number>() // skillId -> base homeY
|
||||
const roleKeyGroups = new Map<string, string[]>() // roleKey -> [skillIds]
|
||||
|
||||
visibleNodeData.filter(n => n.type === 'skill').forEach(n => {
|
||||
const roleIds = visibleLinks.filter(l => l.target === n.id).map(l => l.source)
|
||||
const key = roleIds.slice().sort().join('|')
|
||||
skillRoleKey.set(n.id, key)
|
||||
|
||||
const positions = roleIds
|
||||
.map(roleId => roleInitialMap.get(roleId))
|
||||
.filter(Boolean) as Array<{ x: number; y: number }>
|
||||
const baseY = positions.length > 0
|
||||
? positions.reduce((sum, p) => sum + p.y, 0) / positions.length
|
||||
: height * 0.5
|
||||
skillBaseY.set(n.id, baseY)
|
||||
|
||||
if (!roleKeyGroups.has(key)) roleKeyGroups.set(key, [])
|
||||
roleKeyGroups.get(key)!.push(n.id)
|
||||
})
|
||||
|
||||
// For groups with >1 skill sharing the same roles, apply alternating y-offsets
|
||||
// and x-offsets that scale stronger for skills further left in the zone
|
||||
const skillYOffset = new Map<string, number>()
|
||||
const offsetStep = isMobile ? SKILL_Y_OFFSET_STEP_MOBILE : Math.round(SKILL_Y_OFFSET_STEP * sf)
|
||||
roleKeyGroups.forEach(ids => {
|
||||
if (ids.length <= 1) return
|
||||
ids.forEach((id, i) => {
|
||||
const centered = i - (ids.length - 1) / 2
|
||||
skillYOffset.set(id, centered * offsetStep)
|
||||
})
|
||||
})
|
||||
|
||||
const nodes: SimNode[] = visibleNodeData.map(n => {
|
||||
if (isEntityNode(n.type)) {
|
||||
const pos = roleInitialMap.get(n.id)!
|
||||
return { ...n, x: pos.x, y: pos.y, vx: 0, vy: 0, homeX: pos.x, homeY: pos.y }
|
||||
}
|
||||
|
||||
const hash = hashString(n.id)
|
||||
let homeX = skillZoneLeft + (hash % 1000) / 1000 * skillZoneWidth
|
||||
|
||||
// X-offset for overlapping groups: stronger push for skills further left
|
||||
const key = skillRoleKey.get(n.id) ?? ''
|
||||
const group = roleKeyGroups.get(key)
|
||||
if (group && group.length > 1) {
|
||||
const posInZone = (homeX - skillZoneLeft) / skillZoneWidth // 0 (left) to 1 (right)
|
||||
const pushStrength = 1 - (posInZone * 0) // stronger for left-positioned skills
|
||||
const idx = group.indexOf(n.id)
|
||||
const centered = idx - (group.length - 1) / 2
|
||||
const maxXOffset = skillZoneWidth * SKILL_X_OVERLAP_MAX_RATIO
|
||||
homeX += centered * pushStrength * maxXOffset / Math.max(1, (group.length - 1) / 2)
|
||||
homeX = Math.max(skillZoneLeft, Math.min(skillZoneRight, homeX))
|
||||
}
|
||||
|
||||
const homeY = (skillBaseY.get(n.id) ?? height * 0.5) + (skillYOffset.get(n.id) ?? 0) - height * SKILL_Y_GLOBAL_OFFSET_RATIO
|
||||
|
||||
return { ...n, x: homeX, y: homeY, vx: 0, vy: 0, homeX, homeY }
|
||||
})
|
||||
|
||||
nodesRef.current = nodes
|
||||
|
||||
// Build connected map
|
||||
const connectedMap = new Map<string, Set<string>>()
|
||||
visibleLinks.forEach(l => {
|
||||
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
|
||||
if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set())
|
||||
connectedMap.get(l.source)!.add(l.target)
|
||||
connectedMap.get(l.target)!.add(l.source)
|
||||
})
|
||||
connectedMapRef.current = connectedMap
|
||||
|
||||
// Compute skill rest radii (size encoding by connected role count)
|
||||
const skillRestRadii = new Map<string, number>()
|
||||
nodes.filter(n => n.type === 'skill').forEach(n => {
|
||||
const roleCount = connectedMap.get(n.id)?.size ?? 0
|
||||
skillRestRadii.set(n.id, srDefault + roleCount * SKILL_SIZE_ROLE_FACTOR)
|
||||
})
|
||||
skillRestRadiiRef.current = skillRestRadii
|
||||
|
||||
// Node-by-id lookup for link domain color resolution
|
||||
const nodeById = new Map(visibleNodeData.map(n => [n.id, n]))
|
||||
|
||||
// Create SVG groups
|
||||
const linkGroup = svg.append('g').attr('class', 'links')
|
||||
const connectorGroup = svg.append('g').attr('class', 'connectors')
|
||||
const nodeGroup = svg.append('g').attr('class', 'nodes')
|
||||
|
||||
const linkSelection = linkGroup.selectAll('path')
|
||||
.data(links)
|
||||
.join('path')
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', d => {
|
||||
const skillNode = nodeById.get(d.target as string) ?? nodeById.get(d.source as string)
|
||||
return DOMAIN_COLOR_MAP[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
|
||||
})
|
||||
.attr('stroke-width', d => LINK_BASE_WIDTH + d.strength * LINK_STRENGTH_WIDTH_FACTOR)
|
||||
.attr('stroke-opacity', d => LINK_BASE_OPACITY + d.strength * LINK_STRENGTH_OPACITY_FACTOR)
|
||||
.style('transition', prefersReducedMotion
|
||||
? 'none'
|
||||
: 'stroke 150ms ease, stroke-opacity 150ms ease, stroke-width 150ms ease'
|
||||
)
|
||||
|
||||
linkSelectionRef.current = linkSelection as unknown as d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown>
|
||||
|
||||
const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
|
||||
.data(nodes)
|
||||
.join('g')
|
||||
.attr('class', d => `node node-${d.type}`)
|
||||
.style('cursor', 'pointer')
|
||||
.attr('data-node-id', d => d.id)
|
||||
|
||||
nodeSelectionRef.current = nodeSelection
|
||||
|
||||
// Role + education entity nodes
|
||||
const entityFilter = (d: SimNode) => isEntityNode(d.type)
|
||||
|
||||
nodeSelection.filter(entityFilter)
|
||||
.append('rect')
|
||||
.attr('class', 'focus-ring')
|
||||
.attr('x', -rw / 2 - 3)
|
||||
.attr('y', -rh / 2 - 3)
|
||||
.attr('width', rw + 6)
|
||||
.attr('height', rh + 6)
|
||||
.attr('rx', rrx + 2)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
nodeSelection.filter(entityFilter)
|
||||
.append('rect')
|
||||
.attr('class', 'node-bg')
|
||||
.attr('x', -rw / 2)
|
||||
.attr('y', -rh / 2)
|
||||
.attr('width', rw)
|
||||
.attr('height', rh)
|
||||
.attr('rx', rrx)
|
||||
.attr('fill', 'var(--surface)')
|
||||
|
||||
nodeSelection.filter(entityFilter)
|
||||
.append('rect')
|
||||
.attr('class', 'node-circle')
|
||||
.attr('x', -rw / 2)
|
||||
.attr('y', -rh / 2)
|
||||
.attr('width', rw)
|
||||
.attr('height', rh)
|
||||
.attr('rx', rrx)
|
||||
.attr('fill', d => orgColorGradientMap.get(d.orgColor ?? 'var(--accent)') ?? d.orgColor ?? 'var(--accent)')
|
||||
.attr('data-base-fill', d => orgColorGradientMap.get(d.orgColor ?? 'var(--accent)') ?? d.orgColor ?? 'var(--accent)')
|
||||
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
|
||||
.attr('stroke-opacity', 0.8)
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', d => d.type === 'education' ? '4 3' : null)
|
||||
|
||||
nodeSelection.filter(entityFilter)
|
||||
.append('text')
|
||||
.attr('class', 'node-label')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('fill', d => d.orgColor ?? 'var(--accent)')
|
||||
.attr('font-size', isMobile ? '10' : `${Math.round(12 * sf)}`)
|
||||
.attr('font-weight', '600')
|
||||
.attr('font-family', 'var(--font-ui)')
|
||||
.attr('pointer-events', 'none')
|
||||
.text(d => {
|
||||
const label = d.shortLabel ?? d.label.slice(0, 12)
|
||||
return isMobile && label.length > MOBILE_LABEL_MAX_LEN ? `${label.slice(0, MOBILE_LABEL_MAX_LEN - 1)}…` : label
|
||||
})
|
||||
|
||||
// Skill nodes
|
||||
nodeSelection.filter(d => d.type === 'skill')
|
||||
.append('circle')
|
||||
.attr('class', 'focus-ring')
|
||||
.attr('r', srActive + 3)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
nodeSelection.filter(d => d.type === 'skill')
|
||||
.append('circle')
|
||||
.attr('class', 'node-circle')
|
||||
.attr('r', d => skillRestRadii.get(d.id) ?? srDefault)
|
||||
.attr('fill', d => DOMAIN_COLOR_MAP[d.domain ?? 'technical'] ?? '#0D6E6E')
|
||||
.attr('fill-opacity', 0.35)
|
||||
.attr('stroke', d => DOMAIN_COLOR_MAP[d.domain ?? 'technical'] ?? '#0D6E6E')
|
||||
.attr('stroke-width', SKILL_STROKE_WIDTH)
|
||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||
|
||||
const skillFontSize = isMobile ? 9 : Math.round(11 * sf)
|
||||
const skillLineHeight = Math.round(skillFontSize * 1.15)
|
||||
const skillLabelOffset = srActive + Math.round(14 * sf)
|
||||
|
||||
nodeSelection.filter(d => d.type === 'skill')
|
||||
.append('text')
|
||||
.attr('class', 'node-label')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'var(--text-secondary)')
|
||||
.attr('font-size', skillFontSize)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('pointer-events', 'none')
|
||||
.attr('opacity', 0.5)
|
||||
.each(function (d) {
|
||||
const label = d.shortLabel ?? d.label
|
||||
const words = label.split(/\s+/)
|
||||
const el = d3.select(this)
|
||||
words.forEach((word, i) => {
|
||||
el.append('tspan')
|
||||
.attr('x', 0)
|
||||
.attr('dy', i === 0 ? skillLabelOffset : skillLineHeight)
|
||||
.text(word)
|
||||
})
|
||||
})
|
||||
|
||||
// Entity connectors to timeline
|
||||
const roleConnectors = connectorGroup.selectAll('line.role-connector')
|
||||
.data(nodes.filter(n => isEntityNode(n.type)))
|
||||
.join('line')
|
||||
.attr('class', 'role-connector')
|
||||
.attr('stroke', 'var(--border)')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-opacity', 0.3)
|
||||
|
||||
connectorSelectionRef.current = roleConnectors as unknown as d3.Selection<SVGLineElement, SimNode, SVGGElement, unknown>
|
||||
|
||||
// Simulation
|
||||
const simulation = d3.forceSimulation<SimNode>(nodes)
|
||||
.alpha(0.65)
|
||||
.alphaDecay(prefersReducedMotion ? 0.28 : 0.08)
|
||||
.force('charge', d3.forceManyBody<SimNode>().strength(d =>
|
||||
isEntityNode(d.type) ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf))
|
||||
))
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
||||
.id(d => d.id)
|
||||
.distance(isMobile ? 56 : Math.round(120 * sf))
|
||||
.strength(d => (d as SimLink).strength * 0.15))
|
||||
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.6))
|
||||
.force('y', d3.forceY<SimNode>(d => {
|
||||
if (isEntityNode(d.type)) {
|
||||
return yScale(fractionalYear(d))
|
||||
}
|
||||
return d.homeY
|
||||
}).strength(d => isEntityNode(d.type) ? 0.98 : 0.25))
|
||||
.force('collide', d3.forceCollide<SimNode>(d =>
|
||||
isEntityNode(d.type) ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
|
||||
).iterations(3))
|
||||
|
||||
simulationRef.current = simulation
|
||||
|
||||
const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
|
||||
|
||||
const renderTick = () => {
|
||||
nodes.forEach(d => {
|
||||
if (isEntityNode(d.type)) {
|
||||
d.x = Math.max(rw / 2 + 6, Math.min(axisX - roleGap - rw / 2 + rw / 2, d.x))
|
||||
d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y))
|
||||
} else {
|
||||
d.x = Math.max(srActive + 6, Math.min(skillZoneRight, d.x))
|
||||
d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y))
|
||||
}
|
||||
})
|
||||
|
||||
linkSelection
|
||||
.attr('d', d => {
|
||||
const sx = (d.source as SimNode).x
|
||||
const sy = (d.source as SimNode).y
|
||||
const tx = (d.target as SimNode).x
|
||||
const ty = (d.target as SimNode).y
|
||||
const cx = (sx + tx) / 2 + (ty - sy) * LINK_BEZIER_VERTICAL_OFFSET
|
||||
return `M${sx},${sy} Q${cx},${sy} ${tx},${ty}`
|
||||
})
|
||||
|
||||
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
|
||||
roleConnectors
|
||||
.attr('x1', d => d.x + rw / 2)
|
||||
.attr('y1', d => d.y)
|
||||
.attr('x2', axisX)
|
||||
.attr('y2', d => d.y)
|
||||
|
||||
const nextNodePositions: Record<string, { x: number; y: number }> = {}
|
||||
nodes.forEach(node => {
|
||||
nextNodePositions[node.id] = {
|
||||
x: Math.round(node.x),
|
||||
y: Math.round(node.y),
|
||||
}
|
||||
})
|
||||
|
||||
setNodeButtonPositions(prev => {
|
||||
const prevKeys = Object.keys(prev)
|
||||
const nextKeys = Object.keys(nextNodePositions)
|
||||
if (prevKeys.length !== nextKeys.length) return nextNodePositions
|
||||
|
||||
for (const key of nextKeys) {
|
||||
const prevPos = prev[key]
|
||||
const nextPos = nextNodePositions[key]
|
||||
if (!prevPos || prevPos.x !== nextPos.x || prevPos.y !== nextPos.y) {
|
||||
return nextNodePositions
|
||||
}
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
|
||||
options.applyHighlight(options.resolveGraphFallback())
|
||||
}
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
simulation.stop()
|
||||
for (let i = 0; i < 150; i++) {
|
||||
simulation.tick()
|
||||
}
|
||||
renderTick()
|
||||
} else {
|
||||
simulation.on('tick', renderTick)
|
||||
}
|
||||
|
||||
return () => {
|
||||
simulation.stop()
|
||||
}
|
||||
}, [dimensions, options])
|
||||
|
||||
return {
|
||||
simulationRef,
|
||||
nodesRef,
|
||||
nodeSelectionRef,
|
||||
linkSelectionRef,
|
||||
connectorSelectionRef,
|
||||
yearIndicatorRef,
|
||||
timelineGroupRef,
|
||||
nodeButtonPositions,
|
||||
layoutParams: layoutParamsRef.current,
|
||||
connectedMap: connectedMapRef.current,
|
||||
skillRestRadii: skillRestRadiiRef.current,
|
||||
}
|
||||
}
|
||||
|
||||
export { getHeight }
|
||||
@@ -0,0 +1,593 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { constellationLinks } from '@/data/constellation'
|
||||
import { timelineEntities } from '@/data/timeline'
|
||||
import {
|
||||
ANIM_ENTITY_REVEAL_MS,
|
||||
ANIM_SKILL_REVEAL_MS,
|
||||
ANIM_SKILL_STAGGER_MS,
|
||||
ANIM_LINK_DRAW_MS,
|
||||
ANIM_LINK_STAGGER_MS,
|
||||
ANIM_REINFORCEMENT_MS,
|
||||
ANIM_STEP_GAP_MS,
|
||||
ANIM_HOLD_MS,
|
||||
ANIM_RESET_MS,
|
||||
ANIM_RESTART_DELAY_MS,
|
||||
ANIM_INTERACTION_RESUME_MS,
|
||||
ANIM_SETTLE_ALPHA,
|
||||
ANIM_MONTH_STEP_MS,
|
||||
ANIM_CHRONOLOGICAL_ENABLED,
|
||||
HIDDEN_ENTITY_IDS,
|
||||
prefersReducedMotion,
|
||||
} from '@/components/constellation/constants'
|
||||
import type { SimNode, SimLink, AnimationState, AnimationStep } from '@/components/constellation/types'
|
||||
|
||||
// Pre-compute animation steps from timeline entities (newest first → reverse chronological)
|
||||
const sortedEntities = [...timelineEntities]
|
||||
.filter(e => !HIDDEN_ENTITY_IDS.has(e.id))
|
||||
.sort((a, b) => b.dateRange.startYear - a.dateRange.startYear)
|
||||
|
||||
const MONTH_ABBREVS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
|
||||
|
||||
function buildAnimationSteps(): AnimationStep[] {
|
||||
const seen = new Set<string>()
|
||||
return sortedEntities.map(entity => {
|
||||
const skillIds = entity.skills
|
||||
const newSkillIds = skillIds.filter(s => !seen.has(s))
|
||||
const reinforcedSkillIds = skillIds.filter(s => seen.has(s))
|
||||
skillIds.forEach(s => seen.add(s))
|
||||
const linkPairs = constellationLinks
|
||||
.filter(l => l.source === entity.id)
|
||||
.map(l => ({ source: l.source, target: l.target }))
|
||||
const startDate = new Date(entity.dateRange.start)
|
||||
return {
|
||||
entityId: entity.id,
|
||||
startYear: entity.dateRange.startYear,
|
||||
startMonth: startDate.getMonth(),
|
||||
skillIds,
|
||||
newSkillIds,
|
||||
reinforcedSkillIds,
|
||||
linkPairs,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const animationSteps = buildAnimationSteps()
|
||||
|
||||
interface UseTimelineAnimationDeps {
|
||||
nodeSelectionRef: React.MutableRefObject<d3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null>
|
||||
linkSelectionRef: React.MutableRefObject<d3.Selection<SVGPathElement, SimLink, SVGGElement, unknown> | null>
|
||||
simulationRef: React.MutableRefObject<d3.Simulation<SimNode, SimLink> | null>
|
||||
yearIndicatorRef: React.MutableRefObject<d3.Selection<SVGTextElement, unknown, null, undefined> | null>
|
||||
connectorSelectionRef: React.MutableRefObject<d3.Selection<SVGLineElement, SimNode, SVGGElement, unknown> | null>
|
||||
timelineGroupRef: React.MutableRefObject<d3.Selection<SVGGElement, unknown, null, undefined> | null>
|
||||
skillRestRadiiRef: React.MutableRefObject<Map<string, number>>
|
||||
srDefault: number
|
||||
dimensionsTrigger: number
|
||||
ready?: boolean
|
||||
}
|
||||
|
||||
export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
|
||||
const animationStateRef = useRef<AnimationState>('IDLE')
|
||||
const visibleNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const currentStepRef = useRef(0)
|
||||
const rafIdRef = useRef(0)
|
||||
const timeoutIdsRef = useRef<number[]>([])
|
||||
const userPausedRef = useRef(false)
|
||||
const interactionPausedRef = useRef(false)
|
||||
const resumeTimerRef = useRef(0)
|
||||
const displayedMonthRef = useRef(-1) // 0-indexed, -1 = not yet shown
|
||||
const displayedYearRef = useRef(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [animationInitialized, setAnimationInitialized] = useState(false)
|
||||
|
||||
const scheduleTimeout = useCallback((fn: () => void, ms: number) => {
|
||||
const id = window.setTimeout(fn, ms)
|
||||
timeoutIdsRef.current.push(id)
|
||||
return id
|
||||
}, [])
|
||||
|
||||
// Scroll the month/year indicator from current position to target, one month at a time
|
||||
const scrollDateIndicator = useCallback((
|
||||
targetMonth: number,
|
||||
targetYear: number,
|
||||
onComplete: () => void,
|
||||
) => {
|
||||
const dateGroup = deps.yearIndicatorRef.current
|
||||
if (!dateGroup) { onComplete(); return }
|
||||
|
||||
const monthText = dateGroup.select('.date-month') as d3.Selection<SVGTextElement, unknown, null, undefined>
|
||||
const yearText = dateGroup.select('.date-year') as d3.Selection<SVGTextElement, unknown, null, undefined>
|
||||
const lineHeight = parseFloat(monthText.attr('font-size') || '24') * 1.3
|
||||
|
||||
// First step: just show immediately if nothing displayed yet
|
||||
if (displayedMonthRef.current === -1) {
|
||||
displayedMonthRef.current = targetMonth
|
||||
displayedYearRef.current = targetYear
|
||||
monthText.text(MONTH_ABBREVS[targetMonth])
|
||||
yearText.text(targetYear)
|
||||
dateGroup.transition().duration(400).attr('opacity', 0.6)
|
||||
onComplete()
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate total months to scroll backwards
|
||||
const fromTotal = displayedYearRef.current * 12 + displayedMonthRef.current
|
||||
const toTotal = targetYear * 12 + targetMonth
|
||||
const monthSteps = fromTotal - toTotal // positive = scrolling back in time
|
||||
if (monthSteps <= 0) {
|
||||
// Same or forward — just snap
|
||||
displayedMonthRef.current = targetMonth
|
||||
displayedYearRef.current = targetYear
|
||||
monthText.text(MONTH_ABBREVS[targetMonth])
|
||||
yearText.text(targetYear)
|
||||
onComplete()
|
||||
return
|
||||
}
|
||||
|
||||
let currentMonth = displayedMonthRef.current
|
||||
let currentYear = displayedYearRef.current
|
||||
let step = 0
|
||||
|
||||
const tickMonth = () => {
|
||||
if (step >= monthSteps) {
|
||||
onComplete()
|
||||
return
|
||||
}
|
||||
|
||||
// Step back one month
|
||||
currentMonth--
|
||||
if (currentMonth < 0) {
|
||||
currentMonth = 11
|
||||
currentYear--
|
||||
// Animate year change with vertical slide
|
||||
yearText
|
||||
.transition().duration(ANIM_MONTH_STEP_MS * 0.4)
|
||||
.attr('dy', lineHeight * 0.4)
|
||||
.attr('opacity', 0)
|
||||
.transition().duration(0)
|
||||
.attr('dy', -lineHeight * 0.4)
|
||||
.text(currentYear)
|
||||
.transition().duration(ANIM_MONTH_STEP_MS * 0.4)
|
||||
.attr('dy', 0)
|
||||
.attr('opacity', 0.6)
|
||||
}
|
||||
|
||||
// Animate month with vertical slide
|
||||
monthText
|
||||
.transition().duration(ANIM_MONTH_STEP_MS * 0.4)
|
||||
.attr('dy', lineHeight * 0.4)
|
||||
.attr('opacity', 0)
|
||||
.transition().duration(0)
|
||||
.attr('dy', -lineHeight * 0.4)
|
||||
.text(MONTH_ABBREVS[currentMonth])
|
||||
.transition().duration(ANIM_MONTH_STEP_MS * 0.4)
|
||||
.attr('dy', 0)
|
||||
.attr('opacity', 1)
|
||||
|
||||
displayedMonthRef.current = currentMonth
|
||||
displayedYearRef.current = currentYear
|
||||
step++
|
||||
scheduleTimeout(tickMonth, ANIM_MONTH_STEP_MS)
|
||||
}
|
||||
|
||||
tickMonth()
|
||||
}, [deps.yearIndicatorRef, scheduleTimeout])
|
||||
|
||||
const cancelAll = useCallback(() => {
|
||||
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = 0
|
||||
timeoutIdsRef.current.forEach(id => clearTimeout(id))
|
||||
timeoutIdsRef.current = []
|
||||
if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
|
||||
resumeTimerRef.current = 0
|
||||
}, [])
|
||||
|
||||
const hideAll = useCallback(() => {
|
||||
const nodeSel = deps.nodeSelectionRef.current
|
||||
const linkSel = deps.linkSelectionRef.current
|
||||
const connSel = deps.connectorSelectionRef.current
|
||||
const tlGroup = deps.timelineGroupRef.current
|
||||
const yearInd = deps.yearIndicatorRef.current
|
||||
if (!nodeSel || !linkSel) return
|
||||
|
||||
// Interrupt any running D3 transitions
|
||||
nodeSel.interrupt()
|
||||
linkSel.interrupt()
|
||||
nodeSel.selectAll('*').interrupt()
|
||||
connSel?.interrupt()
|
||||
tlGroup?.interrupt()
|
||||
yearInd?.interrupt()
|
||||
yearInd?.selectAll('*').interrupt()
|
||||
|
||||
nodeSel.style('opacity', '0')
|
||||
linkSel.attr('stroke-opacity', 0)
|
||||
connSel?.attr('opacity', 0)
|
||||
yearInd?.attr('opacity', 0)
|
||||
displayedMonthRef.current = -1
|
||||
displayedYearRef.current = 0
|
||||
|
||||
// Reset skill radii to 0
|
||||
nodeSel.filter((d: SimNode) => d.type === 'skill')
|
||||
.select('.node-circle')
|
||||
.attr('r', 0)
|
||||
|
||||
visibleNodeIdsRef.current = new Set()
|
||||
|
||||
// Show full axis immediately — axis stays visible throughout animation
|
||||
if (tlGroup) {
|
||||
tlGroup.attr('opacity', 1)
|
||||
let minTickY = Infinity
|
||||
tlGroup.selectAll<SVGLineElement, number>('line.year-tick').each(function () {
|
||||
const y = parseFloat(d3.select(this).attr('y1'))
|
||||
if (y < minTickY) minTickY = y
|
||||
})
|
||||
if (minTickY < Infinity) {
|
||||
tlGroup.select('.axis-line').attr('y1', minTickY - 12)
|
||||
}
|
||||
tlGroup.selectAll('line.year-tick').attr('stroke-opacity', 0.8)
|
||||
tlGroup.selectAll('text.year-label').attr('opacity', 1)
|
||||
tlGroup.selectAll('line.year-guide').attr('stroke-opacity', 0.25)
|
||||
}
|
||||
setAnimationInitialized(true)
|
||||
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.timelineGroupRef, deps.yearIndicatorRef])
|
||||
|
||||
const showFinalState = useCallback(() => {
|
||||
const nodeSel = deps.nodeSelectionRef.current
|
||||
const linkSel = deps.linkSelectionRef.current
|
||||
const connSel = deps.connectorSelectionRef.current
|
||||
const tlGroup = deps.timelineGroupRef.current
|
||||
if (!nodeSel || !linkSel) return
|
||||
|
||||
const allIds = new Set<string>()
|
||||
animationSteps.forEach(step => {
|
||||
allIds.add(step.entityId)
|
||||
step.skillIds.forEach(s => allIds.add(s))
|
||||
})
|
||||
visibleNodeIdsRef.current = allIds
|
||||
|
||||
nodeSel.style('opacity', (d: SimNode) => allIds.has(d.id) ? '1' : '0')
|
||||
linkSel.attr('stroke-opacity', null)
|
||||
connSel?.attr('opacity', (d: SimNode) => allIds.has(d.id) ? null : 0)
|
||||
tlGroup?.attr('opacity', 1)
|
||||
|
||||
setAnimationInitialized(true)
|
||||
|
||||
// Show full axis
|
||||
if (tlGroup) {
|
||||
// Find the topmost tick y to set axis line extent
|
||||
let minTickY = Infinity
|
||||
tlGroup.selectAll<SVGLineElement, number>('line.year-tick').each(function () {
|
||||
const y = parseFloat(d3.select(this).attr('y1'))
|
||||
if (y < minTickY) minTickY = y
|
||||
})
|
||||
if (minTickY < Infinity) {
|
||||
tlGroup.select('.axis-line').attr('y1', minTickY - 12)
|
||||
}
|
||||
tlGroup.selectAll('line.year-tick').attr('stroke-opacity', 0.8)
|
||||
tlGroup.selectAll('text.year-label').attr('opacity', 1)
|
||||
tlGroup.selectAll('line.year-guide').attr('stroke-opacity', 0.25)
|
||||
}
|
||||
|
||||
nodeSel.filter((d: SimNode) => d.type === 'skill')
|
||||
.select('.node-circle')
|
||||
.attr('r', (d: SimNode) => deps.skillRestRadiiRef.current.get(d.id) ?? deps.srDefault)
|
||||
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.timelineGroupRef, deps.skillRestRadiiRef, deps.srDefault])
|
||||
|
||||
const revealEntityAndSkills = useCallback((stepIdx: number, onComplete: () => void) => {
|
||||
const nodeSel = deps.nodeSelectionRef.current
|
||||
const linkSel = deps.linkSelectionRef.current
|
||||
const connSel = deps.connectorSelectionRef.current
|
||||
if (!nodeSel || !linkSel) return
|
||||
|
||||
const step = animationSteps[stepIdx]
|
||||
if (!step) { onComplete(); return }
|
||||
|
||||
// Reveal entity node
|
||||
const entityGroup = nodeSel.filter((d: SimNode) => d.id === step.entityId)
|
||||
entityGroup
|
||||
.style('opacity', '0')
|
||||
.transition()
|
||||
.duration(ANIM_ENTITY_REVEAL_MS)
|
||||
.ease(d3.easeBackOut.overshoot(1.2))
|
||||
.style('opacity', '1')
|
||||
|
||||
// Reveal entity connector
|
||||
if (connSel) {
|
||||
connSel.filter((d: SimNode) => d.id === step.entityId)
|
||||
.attr('opacity', 0)
|
||||
.transition()
|
||||
.duration(ANIM_ENTITY_REVEAL_MS)
|
||||
.attr('opacity', 1)
|
||||
}
|
||||
|
||||
visibleNodeIdsRef.current.add(step.entityId)
|
||||
|
||||
// Reveal new skills (staggered)
|
||||
step.newSkillIds.forEach((skillId, i) => {
|
||||
scheduleTimeout(() => {
|
||||
if (animationStateRef.current !== 'PLAYING') return
|
||||
const skillGroup = nodeSel.filter((d: SimNode) => d.id === skillId)
|
||||
skillGroup
|
||||
.style('opacity', '0')
|
||||
.transition()
|
||||
.duration(ANIM_SKILL_REVEAL_MS)
|
||||
.style('opacity', '1')
|
||||
|
||||
const restR = deps.skillRestRadiiRef.current.get(skillId) ?? deps.srDefault
|
||||
skillGroup.select('.node-circle')
|
||||
.attr('r', 0)
|
||||
.transition()
|
||||
.duration(ANIM_SKILL_REVEAL_MS)
|
||||
.ease(d3.easeBackOut)
|
||||
.attr('r', restR)
|
||||
|
||||
visibleNodeIdsRef.current.add(skillId)
|
||||
}, i * ANIM_SKILL_STAGGER_MS)
|
||||
})
|
||||
|
||||
// Reinforcement pulse for already-visible skills
|
||||
step.reinforcedSkillIds.forEach((skillId, i) => {
|
||||
scheduleTimeout(() => {
|
||||
if (animationStateRef.current !== 'PLAYING') return
|
||||
const restR = deps.skillRestRadiiRef.current.get(skillId) ?? deps.srDefault
|
||||
const skillCircle = nodeSel.filter((d: SimNode) => d.id === skillId).select('.node-circle')
|
||||
skillCircle
|
||||
.transition()
|
||||
.duration(ANIM_REINFORCEMENT_MS / 2)
|
||||
.attr('r', restR * 1.3)
|
||||
.transition()
|
||||
.duration(ANIM_REINFORCEMENT_MS / 2)
|
||||
.attr('r', restR)
|
||||
}, i * ANIM_SKILL_STAGGER_MS)
|
||||
})
|
||||
|
||||
// Reveal links (staggered, after skills start appearing)
|
||||
const linkDelay = Math.max(step.newSkillIds.length, 1) * ANIM_SKILL_STAGGER_MS
|
||||
step.linkPairs.forEach((pair, i) => {
|
||||
scheduleTimeout(() => {
|
||||
if (animationStateRef.current !== 'PLAYING') return
|
||||
// Only reveal if both endpoints are visible
|
||||
if (!visibleNodeIdsRef.current.has(pair.source) || !visibleNodeIdsRef.current.has(pair.target)) return
|
||||
|
||||
const linkEl = linkSel.filter((l: SimLink) => {
|
||||
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
|
||||
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
|
||||
return src === pair.source && tgt === pair.target
|
||||
})
|
||||
|
||||
linkEl.each(function () {
|
||||
const el = d3.select(this)
|
||||
const pathEl = this as SVGPathElement
|
||||
const length = pathEl.getTotalLength()
|
||||
el.attr('stroke-opacity', 1)
|
||||
.attr('stroke-dasharray', `${length} ${length}`)
|
||||
.attr('stroke-dashoffset', length)
|
||||
.transition()
|
||||
.duration(ANIM_LINK_DRAW_MS)
|
||||
.attr('stroke-dashoffset', 0)
|
||||
.on('end', function () {
|
||||
d3.select(this)
|
||||
.attr('stroke-dasharray', null)
|
||||
.attr('stroke-dashoffset', null)
|
||||
})
|
||||
})
|
||||
}, linkDelay + i * ANIM_LINK_STAGGER_MS)
|
||||
})
|
||||
|
||||
// Calculate total step duration and call onComplete
|
||||
const skillDuration = Math.max(step.newSkillIds.length, 1) * ANIM_SKILL_STAGGER_MS + ANIM_SKILL_REVEAL_MS
|
||||
const linkDuration = linkDelay + step.linkPairs.length * ANIM_LINK_STAGGER_MS + ANIM_LINK_DRAW_MS
|
||||
const totalStepMs = Math.max(ANIM_ENTITY_REVEAL_MS, skillDuration, linkDuration)
|
||||
|
||||
scheduleTimeout(onComplete, totalStepMs + ANIM_STEP_GAP_MS)
|
||||
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.skillRestRadiiRef, deps.srDefault, scheduleTimeout])
|
||||
|
||||
const revealStep = useCallback((stepIdx: number, onComplete: () => void) => {
|
||||
const step = animationSteps[stepIdx]
|
||||
if (!step) { onComplete(); return }
|
||||
|
||||
// Run date scroll and entity/skills reveal concurrently
|
||||
scrollDateIndicator(step.startMonth, step.startYear, () => {})
|
||||
revealEntityAndSkills(stepIdx, onComplete)
|
||||
}, [scrollDateIndicator, revealEntityAndSkills])
|
||||
|
||||
const runAnimation = useCallback(() => {
|
||||
if (prefersReducedMotion) return
|
||||
|
||||
const advanceStep = () => {
|
||||
if (animationStateRef.current !== 'PLAYING') return
|
||||
|
||||
const stepIdx = currentStepRef.current
|
||||
if (stepIdx >= animationSteps.length) {
|
||||
// All steps done — hold then reset
|
||||
animationStateRef.current = 'HOLDING'
|
||||
scheduleTimeout(() => {
|
||||
if (userPausedRef.current || interactionPausedRef.current) return
|
||||
animationStateRef.current = 'RESETTING'
|
||||
|
||||
// Fade date indicator
|
||||
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
|
||||
// Fade all
|
||||
deps.nodeSelectionRef.current
|
||||
?.transition().duration(ANIM_RESET_MS).style('opacity', '0')
|
||||
deps.linkSelectionRef.current
|
||||
?.transition().duration(ANIM_RESET_MS).attr('stroke-opacity', 0)
|
||||
deps.connectorSelectionRef.current
|
||||
?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
|
||||
scheduleTimeout(() => {
|
||||
if (userPausedRef.current) return
|
||||
// Reset skill radii
|
||||
deps.nodeSelectionRef.current
|
||||
?.filter((d: SimNode) => d.type === 'skill')
|
||||
.select('.node-circle')
|
||||
.attr('r', 0)
|
||||
|
||||
visibleNodeIdsRef.current = new Set()
|
||||
displayedMonthRef.current = -1
|
||||
displayedYearRef.current = 0
|
||||
currentStepRef.current = 0
|
||||
animationStateRef.current = 'PLAYING'
|
||||
setIsPlaying(true)
|
||||
|
||||
scheduleTimeout(advanceStep, ANIM_RESTART_DELAY_MS)
|
||||
}, ANIM_RESET_MS + 50)
|
||||
}, ANIM_HOLD_MS)
|
||||
return
|
||||
}
|
||||
|
||||
revealStep(stepIdx, () => {
|
||||
currentStepRef.current = stepIdx + 1
|
||||
advanceStep()
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for simulation to settle
|
||||
const waitForSettle = () => {
|
||||
const sim = deps.simulationRef.current
|
||||
if (!sim || sim.alpha() > ANIM_SETTLE_ALPHA) {
|
||||
rafIdRef.current = requestAnimationFrame(waitForSettle)
|
||||
return
|
||||
}
|
||||
|
||||
// Simulation settled — hide everything and start
|
||||
hideAll()
|
||||
animationStateRef.current = 'PLAYING'
|
||||
setIsPlaying(true)
|
||||
currentStepRef.current = 0
|
||||
|
||||
scheduleTimeout(advanceStep, 100)
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(waitForSettle)
|
||||
}, [deps.simulationRef, deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, hideAll, revealStep, scheduleTimeout])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (prefersReducedMotion) return
|
||||
|
||||
if (userPausedRef.current) {
|
||||
// Resume
|
||||
userPausedRef.current = false
|
||||
interactionPausedRef.current = false
|
||||
animationStateRef.current = 'RESETTING'
|
||||
|
||||
// Reset and restart
|
||||
hideAll()
|
||||
currentStepRef.current = 0
|
||||
|
||||
scheduleTimeout(() => {
|
||||
animationStateRef.current = 'PLAYING'
|
||||
setIsPlaying(true)
|
||||
runAnimation()
|
||||
}, ANIM_RESTART_DELAY_MS)
|
||||
} else {
|
||||
// Pause
|
||||
userPausedRef.current = true
|
||||
cancelAll()
|
||||
animationStateRef.current = 'PAUSED'
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}, [hideAll, cancelAll, runAnimation, scheduleTimeout])
|
||||
|
||||
const pauseForInteraction = useCallback(() => {
|
||||
if (prefersReducedMotion || userPausedRef.current) return
|
||||
if (animationStateRef.current === 'IDLE') return
|
||||
interactionPausedRef.current = true
|
||||
cancelAll()
|
||||
animationStateRef.current = 'PAUSED'
|
||||
// Don't setIsPlaying(false) — interaction pause is temporary
|
||||
if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
|
||||
}, [cancelAll])
|
||||
|
||||
const resumeAfterInteraction = useCallback(() => {
|
||||
if (prefersReducedMotion || userPausedRef.current) return
|
||||
if (!interactionPausedRef.current) return
|
||||
|
||||
if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
|
||||
resumeTimerRef.current = window.setTimeout(() => {
|
||||
if (userPausedRef.current) return
|
||||
interactionPausedRef.current = false
|
||||
|
||||
// Resume from current state — restart the animation loop from current position
|
||||
animationStateRef.current = 'PLAYING'
|
||||
setIsPlaying(true)
|
||||
|
||||
const advanceFromCurrent = () => {
|
||||
if (animationStateRef.current !== 'PLAYING') return
|
||||
const stepIdx = currentStepRef.current
|
||||
if (stepIdx >= animationSteps.length) {
|
||||
// We were at the end — hold then reset
|
||||
animationStateRef.current = 'HOLDING'
|
||||
scheduleTimeout(() => {
|
||||
if (userPausedRef.current || interactionPausedRef.current) return
|
||||
animationStateRef.current = 'RESETTING'
|
||||
deps.yearIndicatorRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
deps.nodeSelectionRef.current?.transition().duration(ANIM_RESET_MS).style('opacity', '0')
|
||||
deps.linkSelectionRef.current?.transition().duration(ANIM_RESET_MS).attr('stroke-opacity', 0)
|
||||
deps.connectorSelectionRef.current?.transition().duration(ANIM_RESET_MS).attr('opacity', 0)
|
||||
scheduleTimeout(() => {
|
||||
if (userPausedRef.current) return
|
||||
deps.nodeSelectionRef.current
|
||||
?.filter((d: SimNode) => d.type === 'skill')
|
||||
.select('.node-circle')
|
||||
.attr('r', 0)
|
||||
visibleNodeIdsRef.current = new Set()
|
||||
displayedMonthRef.current = -1
|
||||
displayedYearRef.current = 0
|
||||
currentStepRef.current = 0
|
||||
animationStateRef.current = 'PLAYING'
|
||||
setIsPlaying(true)
|
||||
scheduleTimeout(advanceFromCurrent, ANIM_RESTART_DELAY_MS)
|
||||
}, ANIM_RESET_MS + 50)
|
||||
}, ANIM_HOLD_MS)
|
||||
return
|
||||
}
|
||||
revealStep(stepIdx, () => {
|
||||
currentStepRef.current = stepIdx + 1
|
||||
advanceFromCurrent()
|
||||
})
|
||||
}
|
||||
|
||||
advanceFromCurrent()
|
||||
}, ANIM_INTERACTION_RESUME_MS)
|
||||
}, [deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, revealStep, scheduleTimeout])
|
||||
|
||||
// Start animation on mount / dimension change — wait for ready signal
|
||||
useEffect(() => {
|
||||
if (!deps.ready) return
|
||||
|
||||
if (prefersReducedMotion || !ANIM_CHRONOLOGICAL_ENABLED) {
|
||||
// Show final state immediately after a tick to let simulation refs populate
|
||||
const id = requestAnimationFrame(() => {
|
||||
showFinalState()
|
||||
})
|
||||
return () => cancelAnimationFrame(id)
|
||||
}
|
||||
|
||||
// Reset and start animation
|
||||
cancelAll()
|
||||
userPausedRef.current = false
|
||||
interactionPausedRef.current = false
|
||||
animationStateRef.current = 'IDLE'
|
||||
visibleNodeIdsRef.current = new Set()
|
||||
currentStepRef.current = 0
|
||||
runAnimation()
|
||||
|
||||
return () => {
|
||||
cancelAll()
|
||||
animationStateRef.current = 'IDLE'
|
||||
}
|
||||
}, [deps.dimensionsTrigger, deps.ready, cancelAll, runAnimation, showFinalState])
|
||||
|
||||
return {
|
||||
animationStateRef,
|
||||
visibleNodeIdsRef,
|
||||
isPlaying,
|
||||
animationInitialized,
|
||||
togglePlayPause,
|
||||
pauseForInteraction,
|
||||
resumeAfterInteraction,
|
||||
}
|
||||
}
|
||||
+22
-11
@@ -351,6 +351,10 @@ html {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dashboard-grid > :first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: maintain 2 columns with generous gap */
|
||||
@@ -405,16 +409,10 @@ html {
|
||||
|
||||
.timeline-intervention-item {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
|
||||
}
|
||||
|
||||
.timeline-intervention-item--education {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.timeline-intervention-item--education > div {
|
||||
width: min(100%, 94%);
|
||||
}
|
||||
|
||||
.timeline-intervention-pill {
|
||||
display: inline-flex;
|
||||
@@ -438,10 +436,10 @@ html {
|
||||
border-color: rgba(124, 58, 237, 0.28);
|
||||
}
|
||||
|
||||
/* Desktop: 2 columns */
|
||||
@media (min-width: 1024px) {
|
||||
/* Tablet+: 2 columns */
|
||||
@media (min-width: 768px) {
|
||||
.pathway-columns {
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3.5fr);
|
||||
align-items: start;
|
||||
gap: 22px;
|
||||
}
|
||||
@@ -453,6 +451,19 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* Repeat medications 3-column grid */
|
||||
.medications-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.medications-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== COMMAND PALETTE ANIMATIONS ===== */
|
||||
@keyframes palette-overlay-in {
|
||||
from { opacity: 0; }
|
||||
|
||||
+2
-1
@@ -187,11 +187,12 @@ export interface KPIStory {
|
||||
// Constellation-specific types
|
||||
export interface ConstellationNode {
|
||||
id: string
|
||||
type: 'role' | 'skill'
|
||||
type: 'role' | 'skill' | 'education'
|
||||
label: string
|
||||
shortLabel?: string // abbreviated for small nodes
|
||||
organization?: string
|
||||
startYear?: number
|
||||
startDate?: string // ISO date for fractional year positioning
|
||||
endYear?: number | null
|
||||
orgColor?: string
|
||||
domain?: 'clinical' | 'technical' | 'leadership'
|
||||
|
||||
Reference in New Issue
Block a user