diff --git a/spike_visualizer/js/visualization.js b/spike_visualizer/js/visualization.js
new file mode 100644
index 0000000..ecf28d2
--- /dev/null
+++ b/spike_visualizer/js/visualization.js
@@ -0,0 +1,128 @@
+'use strict';
+
+(function(global){
+ const Visualization = {};
+
+ // Internal helpers
+ function clearSVG(svg){
+ if(!svg) return;
+ svg.selectAll('*').remove();
+ }
+
+ function createScales(data, width, height, xField, yField){
+ const xExtent = d3.extent(data, d => d[xField]);
+ const yExtent = d3.extent(data, d => d[yField]);
+
+ return {
+ x: d3.scaleTime().domain(xExtent).range([50, width - 20]),
+ y: d3.scaleLinear().domain(yExtent).range([height - 40, 20])
+ };
+ }
+
+ // --- renderTimeline ---
+ Visualization.renderTimeline = function(spikeData, svgContainer){
+ if(!svgContainer || !Array.isArray(spikeData)) return;
+ clearSVG(svgContainer);
+
+ const width = +svgContainer.attr('width') || 800;
+ const height = +svgContainer.attr('height') || 400;
+
+ const grouped = d3.group(spikeData, d => d.cpu_id);
+ const cpus = Array.from(grouped.keys());
+ const timeExtent = d3.extent(spikeData, d => d.timestamp);
+
+ const xScale = d3.scaleTime().domain(timeExtent).range([80, width - 40]);
+ const yScale = d3.scaleBand().domain(cpus).range([40, height - 40]).padding(0.3);
+
+ const colorScale = d3.scaleSequential(d3.interpolateRdYlBu)
+ .domain(d3.extent(spikeData, d => d.reorder_score));
+
+ // Axes
+ svgContainer.append('g')
+ .attr('transform', `translate(0,${height - 40})`)
+ .call(d3.axisBottom(xScale).ticks(6));
+
+ svgContainer.append('g')
+ .attr('transform', 'translate(80,0)')
+ .call(d3.axisLeft(yScale));
+
+ const tooltip = d3.select('body').append('div')
+ .attr('class', 'tooltip')
+ .style('opacity', 0)
+ .style('position', 'absolute')
+ .style('pointer-events', 'none');
+
+ svgContainer.selectAll('.spike-dot')
+ .data(spikeData)
+ .enter()
+ .append('circle')
+ .attr('class', 'spike-dot')
+ .attr('cx', d => xScale(d.timestamp))
+ .attr('cy', d => yScale(d.cpu_id) + yScale.bandwidth() / 2)
+ .attr('r', 4)
+ .attr('fill', d => colorScale(d.reorder_score))
+ .attr('stroke', d => d.migration_flag ? '#444' : 'none')
+ .on('mouseover', function(event, d){
+ tooltip.transition().duration(200).style('opacity', 0.95);
+ tooltip.html(`CPU: ${d.cpu_id}
Reorder: ${d.reorder_score}
Publish: ${d.publish_rate}`)
+ .style('left', (event.pageX + 5) + 'px')
+ .style('top', (event.pageY - 28) + 'px');
+ })
+ .on('mouseout', function(){
+ tooltip.transition().duration(300).style('opacity', 0);
+ });
+ };
+
+ // --- renderScatter ---
+ Visualization.renderScatter = function(spikeData, filterState){
+ const container = d3.select('#chart-container');
+ if(container.empty() || !Array.isArray(spikeData)) return;
+
+ container.selectAll('*').remove();
+ const width = 800;
+ const height = 400;
+
+ const svg = container.append('svg').attr('width', width).attr('height', height);
+ const scales = createScales(spikeData, width, height, 'timestamp', 'reorder_score');
+
+ svg.append('g')
+ .attr('transform', `translate(0,${height - 40})`)
+ .call(d3.axisBottom(scales.x).ticks(6));
+
+ svg.append('g')
+ .attr('transform', 'translate(50,0)')
+ .call(d3.axisLeft(scales.y));
+
+ const filtered = spikeData.filter(d => {
+ let include = true;
+ if(filterState && filterState.cpu_id) include = include && d.cpu_id === filterState.cpu_id;
+ if(filterState && filterState.min_score != null) include = include && d.reorder_score >= filterState.min_score;
+ if(filterState && filterState.max_score != null) include = include && d.reorder_score <= filterState.max_score;
+ return include;
+ });
+
+ svg.selectAll('.scatter-dot')
+ .data(filtered)
+ .enter()
+ .append('circle')
+ .attr('class', 'scatter-dot')
+ .attr('cx', d => scales.x(d.timestamp))
+ .attr('cy', d => scales.y(d.reorder_score))
+ .attr('r', 4)
+ .attr('fill', '#4287f5')
+ .attr('opacity', 0.75);
+ };
+
+ // --- updateView ---
+ Visualization.updateView = function(filters, newData){
+ const svg = d3.select('#timeline');
+ if(filters && filters.mode === 'scatter') {
+ Visualization.renderScatter(newData, filters);
+ } else {
+ Visualization.renderTimeline(newData, svg);
+ }
+ };
+
+ global.Visualization = Visualization;
+
+})(window);
\ No newline at end of file