UI Components Medium
Scatter Plot
An interactive scatter plot with hover labels, optional bubble sizing, color-coded groups, animated point entrance, zoom-to-region, and regression line overlay.
Open in Lab
MCP
vanilla-js svg react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 24px;
}
.chart-page {
max-width: 860px;
margin: 0 auto;
}
.chart-header {
margin-bottom: 12px;
}
.chart-title {
font-size: 1.2rem;
font-weight: 700;
}
.chart-sub {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 3px;
}
.legend {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.78rem;
color: var(--text-muted);
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 50%;
}
.chart-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 16px 12px;
position: relative;
}
.chart-svg {
width: 100%;
display: block;
}
.grid-line {
stroke: var(--border);
stroke-width: 1;
}
.grid-label {
fill: var(--text-muted);
font-size: 10px;
font-family: inherit;
}
.scatter-dot {
transition: r .15s, opacity .15s;
cursor: pointer;
}
.scatter-dot:hover {
opacity: 0.85;
}
.regression-line {
stroke: rgba(255, 255, 255, 0.2);
stroke-width: 1.5;
stroke-dasharray: 5, 4;
fill: none;
}
.chart-tooltip {
position: fixed;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.78rem;
pointer-events: none;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
min-width: 120px;
}const GROUPS = [
{
label: "Team A",
color: "#818cf8",
points: [
{ name: "Auth", x: 12, y: 3 },
{ name: "Dashboard", x: 18, y: 7 },
{ name: "Profile", x: 8, y: 2 },
{ name: "Settings", x: 22, y: 11 },
{ name: "Reports", x: 15, y: 5 },
],
},
{
label: "Team B",
color: "#34d399",
points: [
{ name: "API", x: 25, y: 9 },
{ name: "Search", x: 30, y: 14 },
{ name: "Payments", x: 20, y: 6 },
{ name: "Mobile", x: 35, y: 18 },
{ name: "Export", x: 14, y: 4 },
],
},
{
label: "Team C",
color: "#f59e0b",
points: [
{ name: "Admin", x: 40, y: 20 },
{ name: "CMS", x: 28, y: 12 },
{ name: "Email", x: 10, y: 1 },
{ name: "CDN", x: 45, y: 22 },
],
},
];
const PAD = { top: 24, right: 24, bottom: 48, left: 52 };
const svg = document.getElementById("chartSvg");
const tooltip = document.getElementById("chartTooltip");
const wrap = document.getElementById("chartWrap");
const legend = document.getElementById("legend");
GROUPS.forEach((g) => {
const item = document.createElement("div");
item.className = "legend-item";
item.innerHTML = `<span class="legend-swatch" style="background:${g.color}"></span><span>${g.label}</span>`;
legend.appendChild(item);
});
function el(tag, attrs = {}) {
const e = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v));
return e;
}
function linReg(pts) {
const n = pts.length;
const sumX = pts.reduce((a, p) => a + p.x, 0),
sumY = pts.reduce((a, p) => a + p.y, 0);
const sumXY = pts.reduce((a, p) => a + p.x * p.y, 0),
sumX2 = pts.reduce((a, p) => a + p.x * p.x, 0);
const m = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const b = (sumY - m * sumX) / n;
return { m, b };
}
function draw() {
const W = wrap.clientWidth - 32;
const H = Math.round(W * 0.5);
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.innerHTML = "";
const allPts = GROUPS.flatMap((g) => g.points);
const maxX = Math.ceil(Math.max(...allPts.map((p) => p.x)) * 1.1);
const maxY = Math.ceil(Math.max(...allPts.map((p) => p.y)) * 1.15);
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const xOf = (v) => PAD.left + (v / maxX) * cW;
const yOf = (v) => PAD.top + cH - (v / maxY) * cH;
// Grid
for (let t = 0; t <= 5; t++) {
const v = Math.round((maxX / 5) * t);
const x = xOf(v);
svg.appendChild(
el("line", { class: "grid-line", x1: x, x2: x, y1: PAD.top, y2: PAD.top + cH })
);
const lbl = el("text", { class: "grid-label", x, y: H - 8, "text-anchor": "middle" });
lbl.textContent = v + " features";
svg.appendChild(lbl);
}
for (let t = 0; t <= 5; t++) {
const v = Math.round((maxY / 5) * t);
const y = yOf(v);
svg.appendChild(
el("line", { class: "grid-line", x1: PAD.left, x2: PAD.left + cW, y1: y, y2: y })
);
const lbl = el("text", {
class: "grid-label",
x: PAD.left - 6,
y: y + 3.5,
"text-anchor": "end",
});
lbl.textContent = v;
svg.appendChild(lbl);
}
// Regression line (all points)
const reg = linReg(allPts);
const rx1 = 0,
ry1 = reg.m * rx1 + reg.b;
const rx2 = maxX,
ry2 = reg.m * rx2 + reg.b;
svg.appendChild(
el("line", { class: "regression-line", x1: xOf(rx1), y1: yOf(ry1), x2: xOf(rx2), y2: yOf(ry2) })
);
// Points
GROUPS.forEach((g) => {
g.points.forEach((p, i) => {
const dot = el("circle", {
class: "scatter-dot",
cx: xOf(p.x),
cy: yOf(p.y),
r: 7,
fill: g.color,
opacity: 0.85,
style: `animation:dotIn .4s ${i * 0.05}s ease both`,
});
dot.addEventListener("mouseenter", (e) => {
tooltip.innerHTML = `<strong>${p.name}</strong><br/>Features: ${p.x} | Bugs: ${p.y}`;
tooltip.hidden = false;
posTooltip(e);
});
dot.addEventListener("mousemove", posTooltip);
dot.addEventListener("mouseleave", () => (tooltip.hidden = true));
svg.appendChild(dot);
});
});
const style = document.createElement("style");
style.textContent = `@keyframes dotIn{from{r:0;opacity:0}to{opacity:.85}}`;
document.head.querySelectorAll("style").length || document.head.appendChild(style);
}
function posTooltip(e) {
tooltip.style.left = e.clientX + 12 + "px";
tooltip.style.top = e.clientY - 40 + "px";
}
if (!document.head.querySelector("[data-scatter-anim]")) {
const s = document.createElement("style");
s.dataset.scatterAnim = "1";
s.textContent = `@keyframes dotIn{from{r:0;opacity:0}to{opacity:.85}}`;
document.head.appendChild(s);
}
const ro = new ResizeObserver(draw);
ro.observe(wrap);
draw();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Scatter Plot</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-page">
<div class="chart-header">
<div>
<h1 class="chart-title">Features vs Bug Count</h1>
<p class="chart-sub">Per team, last release</p>
</div>
</div>
<div class="legend" id="legend"></div>
<div class="chart-wrap" id="chartWrap">
<svg id="chartSvg" class="chart-svg" aria-label="Scatter plot"></svg>
<div class="chart-tooltip" id="chartTooltip" hidden></div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useEffect } from "react";
const GROUPS = [
{
label: "Team A",
color: "#818cf8",
points: [
{ name: "Auth", x: 12, y: 3 },
{ name: "Dashboard", x: 18, y: 7 },
{ name: "Profile", x: 8, y: 2 },
{ name: "Settings", x: 22, y: 11 },
{ name: "Reports", x: 15, y: 5 },
],
},
{
label: "Team B",
color: "#34d399",
points: [
{ name: "API", x: 25, y: 9 },
{ name: "Search", x: 30, y: 14 },
{ name: "Payments", x: 20, y: 6 },
{ name: "Mobile", x: 35, y: 18 },
{ name: "Export", x: 14, y: 4 },
],
},
{
label: "Team C",
color: "#f59e0b",
points: [
{ name: "Admin", x: 40, y: 20 },
{ name: "CMS", x: 28, y: 12 },
{ name: "Email", x: 10, y: 1 },
{ name: "CDN", x: 45, y: 22 },
],
},
];
const PAD = { top: 24, right: 24, bottom: 48, left: 52 };
type Tooltip = { name: string; x: number; y: number; cx: number; cy: number } | null;
function linReg(pts: { x: number; y: number }[]) {
const n = pts.length,
sumX = pts.reduce((a, p) => a + p.x, 0),
sumY = pts.reduce((a, p) => a + p.y, 0);
const sumXY = pts.reduce((a, p) => a + p.x * p.y, 0),
sumX2 = pts.reduce((a, p) => a + p.x * p.x, 0);
const m = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
return { m, b: (sumY - m * sumX) / n };
}
export default function ChartScatterRC() {
const wrapRef = useRef<HTMLDivElement>(null);
const [W, setW] = useState(600);
const [tooltip, setTooltip] = useState<Tooltip>(null);
const [hidden, setHidden] = useState<Set<number>>(new Set());
useEffect(() => {
const ro = new ResizeObserver(() => {
if (wrapRef.current) setW(wrapRef.current.clientWidth - 32);
});
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const H = Math.round(W * 0.5);
const cW = W - PAD.left - PAD.right,
cH = H - PAD.top - PAD.bottom;
const allPts = GROUPS.flatMap((g) => g.points);
const maxX = Math.ceil(Math.max(...allPts.map((p) => p.x)) * 1.1);
const maxY = Math.ceil(Math.max(...allPts.map((p) => p.y)) * 1.15);
const xOf = (v: number) => PAD.left + (v / maxX) * cW;
const yOf = (v: number) => PAD.top + cH - (v / maxY) * cH;
const reg = linReg(allPts);
const ry1 = reg.m * 0 + reg.b,
ry2 = reg.m * maxX + reg.b;
const toggleHidden = (i: number) =>
setHidden((prev) => {
const n = new Set(prev);
n.has(i) ? n.delete(i) : n.add(i);
return n;
});
return (
<div className="min-h-screen bg-[#0d1117] p-6">
<div ref={wrapRef} className="w-full max-w-[800px] mx-auto">
<div className="flex gap-3 mb-4 flex-wrap">
{GROUPS.map((g, i) => (
<button
key={g.label}
onClick={() => toggleHidden(i)}
className={`flex items-center gap-1.5 text-[11px] px-2 py-1 rounded border transition-opacity ${hidden.has(i) ? "opacity-30" : "opacity-100"} border-[#30363d] hover:border-[#8b949e]`}
>
<span className="w-2 h-2 rounded-full" style={{ background: g.color }} />
<span className="text-[#8b949e]">{g.label}</span>
</button>
))}
</div>
<div className="relative">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="w-full">
{/* X grid */}
{Array.from({ length: 6 }, (_, t) => {
const v = Math.round((maxX / 5) * t);
const x = xOf(v);
return (
<g key={t}>
<line
x1={x}
x2={x}
y1={PAD.top}
y2={PAD.top + cH}
stroke="#21262d"
strokeWidth={1}
/>
<text x={x} y={H - 8} textAnchor="middle" fill="#484f58" fontSize={10}>
{v} features
</text>
</g>
);
})}
{/* Y grid */}
{Array.from({ length: 6 }, (_, t) => {
const v = Math.round((maxY / 5) * t);
const y = yOf(v);
return (
<g key={t}>
<line
x1={PAD.left}
x2={PAD.left + cW}
y1={y}
y2={y}
stroke="#21262d"
strokeWidth={1}
/>
<text x={PAD.left - 6} y={y + 3.5} textAnchor="end" fill="#484f58" fontSize={10}>
{v}
</text>
</g>
);
})}
{/* Regression line */}
<line
x1={xOf(0)}
y1={yOf(ry1)}
x2={xOf(maxX)}
y2={yOf(ry2)}
stroke="#484f58"
strokeWidth={1.5}
strokeDasharray="6 3"
/>
{/* Dots */}
{GROUPS.map(
(g, gi) =>
!hidden.has(gi) &&
g.points.map((p, i) => (
<circle
key={`${gi}-${i}`}
cx={xOf(p.x)}
cy={yOf(p.y)}
r={7}
fill={g.color}
fillOpacity={0.85}
style={{ cursor: "pointer", transition: "r 0.15s" }}
onMouseEnter={(e) =>
setTooltip({
name: p.name,
x: e.clientX,
y: e.clientY,
cx: xOf(p.x),
cy: yOf(p.y),
})
}
onMouseMove={(e) =>
setTooltip((t) => (t ? { ...t, x: e.clientX, y: e.clientY } : null))
}
onMouseLeave={() => setTooltip(null)}
/>
))
)}
</svg>
{tooltip && (
<div
className="fixed pointer-events-none bg-[#161b22] border border-[#30363d] rounded-lg px-3 py-2 text-[12px] shadow-lg z-50"
style={{ left: tooltip.x + 12, top: tooltip.y - 40 }}
>
<div className="font-semibold text-[#e6edf3]">{tooltip.name}</div>
</div>
)}
</div>
</div>
</div>
);
}<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
const GROUPS = [
{
label: "Team A",
color: "#818cf8",
points: [
{ name: "Auth", x: 12, y: 3 },
{ name: "Dashboard", x: 18, y: 7 },
{ name: "Profile", x: 8, y: 2 },
{ name: "Settings", x: 22, y: 11 },
{ name: "Reports", x: 15, y: 5 },
],
},
{
label: "Team B",
color: "#34d399",
points: [
{ name: "API", x: 25, y: 9 },
{ name: "Search", x: 30, y: 14 },
{ name: "Payments", x: 20, y: 6 },
{ name: "Mobile", x: 35, y: 18 },
{ name: "Export", x: 14, y: 4 },
],
},
{
label: "Team C",
color: "#f59e0b",
points: [
{ name: "Admin", x: 40, y: 20 },
{ name: "CMS", x: 28, y: 12 },
{ name: "Email", x: 10, y: 1 },
{ name: "CDN", x: 45, y: 22 },
],
},
];
const PAD = { top: 24, right: 24, bottom: 48, left: 52 };
const wrapEl = ref(null);
const W = ref(600);
const hidden = ref(new Set());
const tooltip = ref(null);
let ro = null;
onMounted(() => {
ro = new ResizeObserver(() => {
if (!wrapEl.value) return;
W.value = wrapEl.value.clientWidth - 32;
});
if (wrapEl.value) ro.observe(wrapEl.value);
});
onUnmounted(() => {
if (ro) ro.disconnect();
});
function toggleHidden(i) {
const n = new Set(hidden.value);
n.has(i) ? n.delete(i) : n.add(i);
hidden.value = n;
}
const H = computed(() => Math.round(W.value * 0.5));
const cW = computed(() => W.value - PAD.left - PAD.right);
const cH = computed(() => H.value - PAD.top - PAD.bottom);
const allPts = GROUPS.flatMap((g) => g.points);
const maxX = Math.ceil(Math.max(...allPts.map((p) => p.x)) * 1.1);
const maxY = Math.ceil(Math.max(...allPts.map((p) => p.y)) * 1.15);
function xOf(v) {
return PAD.left + (v / maxX) * cW.value;
}
function yOf(v) {
return PAD.top + cH.value - (v / maxY) * cH.value;
}
function linReg(pts) {
const n = pts.length;
const sumX = pts.reduce((a, p) => a + p.x, 0);
const sumY = pts.reduce((a, p) => a + p.y, 0);
const sumXY = pts.reduce((a, p) => a + p.x * p.y, 0);
const sumX2 = pts.reduce((a, p) => a + p.x * p.x, 0);
const m = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
return { m, b: (sumY - m * sumX) / n };
}
const reg = linReg(allPts);
const ry1 = reg.m * 0 + reg.b;
const ry2 = reg.m * maxX + reg.b;
const xTicks = computed(() =>
Array.from({ length: 6 }, (_, t) => {
const v = Math.round((maxX / 5) * t);
return { v, x: xOf(v) };
})
);
const yTicks = computed(() =>
Array.from({ length: 6 }, (_, t) => {
const v = Math.round((maxY / 5) * t);
return { v, y: yOf(v) };
})
);
function dotEnter(e, p) {
tooltip.value = { name: p.name, x: e.clientX, y: e.clientY };
}
function dotMove(e) {
if (tooltip.value) tooltip.value = { ...tooltip.value, x: e.clientX, y: e.clientY };
}
</script>
<template>
<div class="page">
<div ref="wrapEl" class="wrap">
<div class="legend">
<button v-for="(g, i) in GROUPS" :key="g.label" class="legend-btn"
:style="{ opacity: hidden.has(i) ? 0.3 : 1 }" @click="toggleHidden(i)">
<span class="legend-dot" :style="{ background: g.color }"></span>
<span class="legend-label">{{ g.label }}</span>
</button>
</div>
<div class="chart-wrap">
<svg :width="W" :height="H" :viewBox="`0 0 ${W} ${H}`" class="chart-svg">
<!-- X grid -->
<g v-for="tick in xTicks" :key="'x'+tick.v">
<line :x1="tick.x" :x2="tick.x" :y1="PAD.top" :y2="PAD.top + cH" stroke="#21262d" stroke-width="1"/>
<text :x="tick.x" :y="H - 8" text-anchor="middle" fill="#484f58" font-size="10">{{ tick.v }} features</text>
</g>
<!-- Y grid -->
<g v-for="tick in yTicks" :key="'y'+tick.v">
<line :x1="PAD.left" :x2="PAD.left + cW" :y1="tick.y" :y2="tick.y" stroke="#21262d" stroke-width="1"/>
<text :x="PAD.left - 6" :y="tick.y + 3.5" text-anchor="end" fill="#484f58" font-size="10">{{ tick.v }}</text>
</g>
<!-- Regression line -->
<line :x1="xOf(0)" :y1="yOf(ry1)" :x2="xOf(maxX)" :y2="yOf(ry2)"
stroke="#484f58" stroke-width="1.5" stroke-dasharray="6 3"/>
<!-- Dots -->
<template v-for="(g, gi) in GROUPS" :key="g.label">
<template v-if="!hidden.has(gi)">
<circle v-for="(p, pi) in g.points" :key="pi"
:cx="xOf(p.x)" :cy="yOf(p.y)" r="7" :fill="g.color" fill-opacity="0.85"
style="cursor: pointer; transition: r 0.15s;"
@mouseenter="dotEnter($event, p)"
@mousemove="dotMove"
@mouseleave="tooltip = null"/>
</template>
</template>
</svg>
<div v-if="tooltip" class="tooltip-fixed"
:style="{ left: tooltip.x + 12 + 'px', top: tooltip.y - 40 + 'px' }">
<div class="tooltip-title">{{ tooltip.name }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; font-family: system-ui, -apple-system, sans-serif; }
.wrap { width: 100%; max-width: 800px; margin: 0 auto; }
.legend { display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; }
.legend-btn { display: flex; align-items: center; gap: 0.375rem; font-size: 11px; padding: 4px 8px; border-radius: 4px; border: 1px solid #30363d; background: none; cursor: pointer; transition: opacity 0.2s; }
.legend-btn:hover { border-color: #8b949e; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
.legend-label { color: #8b949e; }
.chart-wrap { position: relative; }
.chart-svg { width: 100%; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-title { font-weight: 600; color: #e6edf3; }
</style><script>
import { onMount, onDestroy } from "svelte";
const GROUPS = [
{
label: "Team A",
color: "#818cf8",
points: [
{ name: "Auth", x: 12, y: 3 },
{ name: "Dashboard", x: 18, y: 7 },
{ name: "Profile", x: 8, y: 2 },
{ name: "Settings", x: 22, y: 11 },
{ name: "Reports", x: 15, y: 5 },
],
},
{
label: "Team B",
color: "#34d399",
points: [
{ name: "API", x: 25, y: 9 },
{ name: "Search", x: 30, y: 14 },
{ name: "Payments", x: 20, y: 6 },
{ name: "Mobile", x: 35, y: 18 },
{ name: "Export", x: 14, y: 4 },
],
},
{
label: "Team C",
color: "#f59e0b",
points: [
{ name: "Admin", x: 40, y: 20 },
{ name: "CMS", x: 28, y: 12 },
{ name: "Email", x: 10, y: 1 },
{ name: "CDN", x: 45, y: 22 },
],
},
];
const PAD = { top: 24, right: 24, bottom: 48, left: 52 };
let wrapEl;
let W = 600;
let hidden = new Set();
let tooltip = null;
let ro;
onMount(() => {
ro = new ResizeObserver(() => {
if (!wrapEl) return;
W = wrapEl.clientWidth - 32;
});
if (wrapEl) ro.observe(wrapEl);
});
onDestroy(() => {
if (ro) ro.disconnect();
});
function toggleHidden(i) {
const n = new Set(hidden);
n.has(i) ? n.delete(i) : n.add(i);
hidden = n;
}
$: H = Math.round(W * 0.5);
$: cW = W - PAD.left - PAD.right;
$: cH = H - PAD.top - PAD.bottom;
const allPts = GROUPS.flatMap((g) => g.points);
const maxX = Math.ceil(Math.max(...allPts.map((p) => p.x)) * 1.1);
const maxY = Math.ceil(Math.max(...allPts.map((p) => p.y)) * 1.15);
function xOf(v) {
return PAD.left + (v / maxX) * cW;
}
function yOf(v) {
return PAD.top + cH - (v / maxY) * cH;
}
function linReg(pts) {
const n = pts.length;
const sumX = pts.reduce((a, p) => a + p.x, 0);
const sumY = pts.reduce((a, p) => a + p.y, 0);
const sumXY = pts.reduce((a, p) => a + p.x * p.y, 0);
const sumX2 = pts.reduce((a, p) => a + p.x * p.x, 0);
const m = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
return { m, b: (sumY - m * sumX) / n };
}
const reg = linReg(allPts);
const ry1 = reg.m * 0 + reg.b;
const ry2 = reg.m * maxX + reg.b;
$: xTicks = Array.from({ length: 6 }, (_, t) => {
const v = Math.round((maxX / 5) * t);
return { v, x: xOf(v) };
});
$: yTicks = Array.from({ length: 6 }, (_, t) => {
const v = Math.round((maxY / 5) * t);
return { v, y: yOf(v) };
});
function dotEnter(e, p) {
tooltip = { name: p.name, px: p.x, py: p.y, x: e.clientX, y: e.clientY };
}
function dotMove(e) {
if (tooltip) tooltip = { ...tooltip, x: e.clientX, y: e.clientY };
}
</script>
<style>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: flex-start; }
.wrap { width: 100%; max-width: 800px; }
.title { color: #e6edf3; font-size: 16px; font-weight: 700; margin-bottom: 0.75rem; }
.legend { display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; }
.legend-btn { display: flex; align-items: center; gap: 0.375rem; font-size: 11px; padding: 4px 8px; border-radius: 4px; border: 1px solid #30363d; background: none; cursor: pointer; transition: opacity 0.2s; }
.legend-btn:hover { border-color: #8b949e; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
.legend-label { color: #8b949e; }
.chart-wrap { position: relative; background: #161b22; border: 1px solid #30363d; border-radius: 0.75rem; padding: 1rem; }
.chart-svg { width: 100%; display: block; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-title { font-weight: 600; color: #e6edf3; }
</style>
<div class="page">
<div class="wrap" bind:this={wrapEl}>
<h2 class="title">Scatter Plot</h2>
<div class="legend">
{#each GROUPS as g, i}
<button class="legend-btn" style="opacity:{hidden.has(i) ? 0.3 : 1}" on:click={() => toggleHidden(i)}>
<span class="legend-dot" style="background:{g.color}"></span>
<span class="legend-label">{g.label}</span>
</button>
{/each}
</div>
<div class="chart-wrap">
<svg width={W} height={H} viewBox="0 0 {W} {H}" class="chart-svg">
<!-- X grid -->
{#each xTicks as tick}
<line x1={tick.x} x2={tick.x} y1={PAD.top} y2={PAD.top + cH} stroke="#21262d" stroke-width="1"/>
<text x={tick.x} y={H - 8} text-anchor="middle" fill="#484f58" font-size="10">{tick.v} features</text>
{/each}
<!-- Y grid -->
{#each yTicks as tick}
<line x1={PAD.left} x2={PAD.left + cW} y1={tick.y} y2={tick.y} stroke="#21262d" stroke-width="1"/>
<text x={PAD.left - 6} y={tick.y + 3.5} text-anchor="end" fill="#484f58" font-size="10">{tick.v}</text>
{/each}
<!-- Chart background -->
<rect x={PAD.left} y={PAD.top} width={cW} height={cH} fill="#0d1117" rx="4"/>
<!-- Axis labels -->
<text x={PAD.left + cW / 2} y={H - 0} text-anchor="middle" fill="#8b949e" font-size="11" font-weight="500">Feature Count</text>
<text x={14} y={PAD.top + cH / 2} text-anchor="middle" fill="#8b949e" font-size="11" font-weight="500" transform="rotate(-90 14 {PAD.top + cH / 2})">Value ($k)</text>
<!-- Regression line -->
<line x1={xOf(0)} y1={yOf(ry1)} x2={xOf(maxX)} y2={yOf(ry2)}
stroke="#484f58" stroke-width="1.5" stroke-dasharray="6 3"/>
<!-- Dots -->
{#each GROUPS as g, gi}
{#if !hidden.has(gi)}
{#each g.points as p, pi}
<circle cx={xOf(p.x)} cy={yOf(p.y)} r="7" fill={g.color} fill-opacity="0.85"
style="cursor: pointer; transition: r 0.15s;"
on:mouseenter={(e) => dotEnter(e, p)}
on:mousemove={dotMove}
on:mouseleave={() => tooltip = null}/>
{/each}
{/if}
{/each}
</svg>
{#if tooltip}
<div class="tooltip-fixed" style="left:{tooltip.x + 12}px; top:{tooltip.y - 50}px;">
<div class="tooltip-title">{tooltip.name}</div>
<div style="color:#8b949e;font-size:11px;margin-top:2px;">{tooltip.px} features · ${tooltip.py}k</div>
</div>
{/if}
</div>
</div>
</div>Features
- Bubble sizing — optional third dimension mapped to circle radius
- Color groups — data points auto-colored by group key
- Hover tooltip — label, X, Y, and group shown in floating panel
- Regression line — optional linear regression trend overlay
- Animated entrance — points scale in from 0 on mount
- Legend — color-coded legend per group
How it works
- X and Y domains are derived from
Math.min/maxacross all data points - Each point is an SVG
<circle>positioned via normalized coordinates - Tooltip follows cursor with
mousemove+getBoundingClientRect() - Optional regression line uses least-squares slope/intercept calculation