You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

929 lines
29 KiB

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Design Game</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'JetBrains Mono', monospace;
background-color: #161b22;
color: #ccc;
display: flex;
flex-direction: row;
height: 100vh;
}
#sidebar {
width: 100%;
background-color: #111;
display: flex;
flex-direction: row;
gap: 12px;
flex-wrap: wrap;
}
.component-icon, #arrow-tool {
padding: 10px 12px;
background-color: #1e1e1e;
border: 1px solid #444;
border-radius: 4px;
text-align: center;
cursor: grab;
user-select: none;
font-size: 16px;
transition: background-color 0.1s ease;
color: rgb(204, 204, 204);
}
.component-icon:hover, #arrow-tool:hover {
background-color: #2a2a2a;
border: 1px solid #00ff88;
}
.component-icon:active, #arrow-tool:active {
cursor: grabbing;
}
#arrow-tool.active {
background-color: #005f87;
color: white;
border-color: #007acc;
}
#canvas-container {
flex: 1;
position: relative;
background: #121212;
box-sizing: border-box;
height: 100%;
margin: 16px 0 0;
}
#canvas-wrapper {
flex: 1;
display: flex;
flex-direction: column;
border-radius: 8px;
border: 2px solid #30363d;
overflow: hidden;
background: #121212;
margin: 12px 12px 12px 0;
padding: 16px;
}
#canvas {
width: 100%;
height: 90%;
background: #121212;
border: 2px dashed #30363d;
border-radius: 8px;
}
.dropped {
cursor: move;
}
.dropped.selected rect {
stroke: #00bcd4;
stroke-width: 2;
}
/* === PANELS === */
#info-panel {
position: absolute;
top: 12px;
right: 12px;
background: var(--color-bg-dark);
color: var(--color-text-primary);
padding: 1rem;
border-radius: var(--radius-large);
font-family: monospace;
font-size: 14px;
min-width: 220px;
z-index: 10;
border: 1px solid var(--color-text-dark);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
}
#node-props-panel {
position: absolute;
top: 20px;
right: 20px;
width: 220px;
background-color: #111;
border: 1px solid #444;
border-radius: 4px;
padding: 12px;
color: #eee;
box-shadow: 0 0 10px rgba(0,0,0,0.6);
display: none;
z-index: 10;
}
#node-props-panel h3 {
margin-top: 0;
font-size: 15px;
color: #ccc;
}
.prop-group {
display: none;
margin-bottom: 12px;
}
.prop-group label, .prop-group input {
display: block;
width: 100%;
margin-top: 6px;
font-size: 13px;
}
input[type="text"],
input[type="number"] {
padding: 6px;
background-color: #222;
border: 1px solid #555;
color: #eee;
border-radius: 4px;
font-family: 'Fira Code', monospace;
}
#node-props-panel button {
padding: 8px;
background-color: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-top: 8px;
}
#node-props-panel button:disabled {
background-color: #555;
cursor: not-allowed;
}
#info-panel {
position: absolute;
top: 12px;
right: 12px;
background: #121212;
color: #ccc;
padding: 1rem;
border-radius: 8px;
font-family: monospace;
font-size: 14px;
min-width: 220px;
z-index: 10;
border: 1px solid #333;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
}
#constraints-panel,
#score-panel {
margin-bottom: 1rem;
}
.panel-title {
font-weight: bold;
color: #fff;
font-size: 15px;
margin-bottom: 0.5rem;
}
.panel-metric {
margin-bottom: 0.4rem;
}
.panel-metric .label {
display: inline-block;
width: 140px;
color: #888;
}
.component-icon {
position: relative;
padding: 8px;
background: #1e1e1e;
color: rgb(204, 204, 204);
border: 1px solid #444;
border-radius: 6px;
cursor: grab;
}
.component-icon .tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
top: 100%;
left: 0;
z-index: 10;
background: #333;
color: #fff;
padding: 6px 8px;
border-radius: 4px;
white-space: nowrap;
font-size: 12px;
line-height: 1.4;
margin-top: 4px;
transition: opacity 0.2s;
}
.component-icon:hover .tooltip {
visibility: visible;
opacity: 1;
z-index:1000;
}
.component-icon.dragging .tooltip {
display: none;
}
#challenge-container {
width: 15%;
box-sizing: border-box;
margin-left: 16px;
margin-top: 24px;
}
.tabs {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.tab-labels {
display: flex;
cursor: pointer;
}
.tab-labels label {
padding: 10px 20px;
background: #161b22;
margin-right: 4px;
margin-bottom: 20px;
border-radius: 4px;
}
.tab-content {
border-top: 1px solid #30363d;
padding: 20px 0 0;
display: none;
height: 100%;
}
input[name="tab"] {
display: none;
}
#tab1:checked ~ .tabs .tab-labels label[for="tab1"],
#tab2:checked ~ .tabs .tab-labels label[for="tab2"],
#tab3:checked ~ .tabs .tab-labels label[for="tab3"] {
background: #1a3d2a;
font-weight: bold;
color: #00ff88;
}
#tab1:checked ~ .tabs #content1,
#tab2:checked ~ .tabs #content2,
#tab3:checked ~ .tabs #content3 {
display: flex;
flex-direction: column;
height: 100%;
overflow:hidden;
}
#sd-header {
width: 100%;
background: none;
padding: 12px 24px;
font-size: 24px;
font-weight: bold;
color: #00ff88;
border-bottom: 1px solid #333;
}
#page-container {
display: flex;
flex-direction: column;
width: 100%;
}
#main-content {
display: flex;
flex-direction: row;
height: 100%;
background: radial-gradient(circle at 30% 50%, rgba(0, 255, 136, 0.1) 0%, transparent 50%),
radial-gradient(circle at 70% 80%, rgba(255, 107, 53, 0.1) 0%, transparent 50%)
}
#challenge-container {
background: #121212;
margin: 12px 24px;
border: 2px solid #30363d;
border-radius: 8px;
}
</style>
</head>
<body>
<div id="page-container">
<div id="sd-header">System Design Game</div>
<div id="main-content">
<div id="challenge-container">
<h2 class="sidebar-title">Challenges</h2>
<ul class="challenge-list">
<li class="challenge-item active">
<div class="challenge-name">Url Shortener</div>
<div class="challenge-difficulty easy">Easy</div>
</li>
<li class="challenge-item">
<div class="challenge-name">Url Shortener</div>
<div class="challenge-difficulty easy">Easy</div>
</li>
<li class="challenge-item">
<div class="challenge-name">Url Shortener</div>
<div class="challenge-difficulty medium">Medium</div>
</li>
<li class="challenge-item">
<div class="challenge-name">Something hard</div>
<div class="challenge-difficulty hard">Hard</div>
</li>
</ul>
</div>
<div id="canvas-wrapper">
<input type="radio" id="tab1" name="tab" checked>
<input type="radio" id="tab2" name="tab">
<input type="radio" id="tab3" name="tab">
<div class="tabs">
<div class="tab-labels">
<label for="tab1">Requirements</label>
<label for="tab2">Design</label>
<label for="tab3">Metrics</label>
</div>
<!-- Requirements -->
<div id="content1" class="tab-content">
<div class="requirements-section">
<h3>Functional Requirements</h3>
<ul class="requirements-list">
<li class="requirement-item">Something</li>
<li class="requirement-item">Something else</li>
</ul>
</div>
<!-- Design-->
<div id="content2" class="tab-content">
<div id="sidebar">
<div class="component-icon" draggable="true" data-type="user">
user
<span class="tooltip">simulates user traffic</span>
</div>
<div class="component-icon" draggable="true" data-type="loadbalancer">
load balancer
<span class="tooltip">cost: $5/mo<br>distributes traffic evenly<br>latency: 5 ms</span>
</div>
<div class="component-icon" draggable="true" data-type="webserver (small)">
web server (small)
<span class="tooltip">cost: $10/mo<br>capacity: 100 rps<br>base latency: 50 ms</span>
</div>
<div class="component-icon" draggable="true" data-type="webserver (medium)">
web server (medium)
<span class="tooltip">cost: $20/mo<br>capacity: 200 rps<br>base latency: 40 ms</span>
</div>
<div class="component-icon" draggable="true" data-type="database">
database
<span class="tooltip">cost: $20/mo<br>read capacity: 150 rps<br>base latency: 80 ms<br>supports replication</span>
</div>
<div class="component-icon" draggable="true" data-type="cachestandard">
cache (standard)
<span class="tooltip">cost: $10/mo<br>capacity: 100 rps<br>latency: 5 ms<br>80% hit rate with 1hr ttl</span>
</div>
<div class="component-icon" draggable="true" data-type="cachelarge">
cache (large)
<span class="tooltip">cost: $20/mo<br>capacity: 200 rps<br>latency: 5 ms<br>higher hit rate for large datasets</span>
</div>
<div class="component-icon" draggable="true" data-type="messagequeue">
message queue
<span class="tooltip">cost: $15/mo<br>decouples components<br>useful for batching writes</span>
</div>
<div class="component-icon" draggable="true" data-type="cdn">
cdn/edge cache
<span class="tooltip">cost: $0.03/gb<br>improves global latency<br>caches static content</span>
</div>
<div class="component-icon" draggable="true" data-type="microservice">
microservice node
<span class="tooltip">cost: $10/mo<br>stateless container<br>use for modular logic</span>
</div>
<div class="component-icon" draggable="true" data-type="datapipeline">
data pipeline
<span class="tooltip">cost: $25/mo<br>stream or batch processing<br>used for analytics / etl</span>
</div>
<div class="component-icon" draggable="true" data-type="monitoring">
monitoring/alerting
<span class="tooltip">cost: $5/mo<br>health checks + logs<br>alerts on failures</span>
</div>
<div class="component-icon" draggable="true" data-type="thirdparty">
third-party service
<span class="tooltip">external apis<br>latency + cost vary<br>examples: payment, email, search</span>
</div>
<div id="arrow-tool">arrow tool</div>
</div>
<div id="canvas-container">
<div id="info-panel">
<div id="constraints-panel">
<div class="panel-title">level constraints</div>
<div class="panel-metric"><span class="label">🎯 target rps:</span> <span id="constraint-rps"></span></div>
<div class="panel-metric"><span class="label"> max p95 latency:</span> <span id="constraint-latency"></span></div>
<div class="panel-metric"><span class="label">💸 max cost:</span> <span id="constraint-cost"></span></div>
<div class="panel-metric"><span class="label">🔒 availability:</span> <span id="constraint-availability"></span></div>
</div>
<div id="score-panel">
<div class="panel-title">simulation results</div>
<div class="panel-metric"><span class="label">✅ cost:</span> <span id="score-cost"></span></div>
<div class="panel-metric"><span class="label">⚡ p95 latency:</span> <span id="score-p95"></span></div>
<div class="panel-metric"><span class="label">📈 achieved rps:</span> <span id="score-rps"></span></div>
<div class="panel-metric"><span class="label">🛡 availability:</span> <span id="score-availability"></span></div>
</div>
</div>
<svg id="canvas">
<defs>
<marker id="arrowhead" markerwidth="10" markerheight="7" refx="10" refy="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#333" />
</marker>
</defs>
</svg>
<div id="node-props-panel">
<h3>node properties</h3>
<div id="label-group">
<label>label:<input type="text" name="label" /></label>
</div>
<div id="db-group" class="prop-group">
<label>replication factor:<input type="number" name="replication" min="1" step="1" /></label>
</div>
<div id="cache-group" class="prop-group">
<label>cache ttl (secs):<input type="number" name="cachettl" min="0" step="60" /></label>
</div>
<button id="node-props-save" disabled>save</button>
</div>
<div id="bottom-panel">
<button id="run-button" disabled>Test Design</button>
</div>
</div>
</div>
<!-- Metrics-->
<div id="content3" class="tab-content">This is Tab 3 content.</div>
</div>
</div>
</div>
</div>
<script>
let nodeIdCounter = 1;
function generateNodeId() {
return `node-${nodeIdCounter++}`;
}
function createSVGElement(tag, attrs) {
const elem = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (let key in attrs) {
elem.setAttribute(key, attrs[key]);
}
return elem;
}
class Node {
constructor(type, x, y, app) {
this.id = generateNodeId();
this.type = type;
this.app = app;
this.props = {
label: type,
replication: 1,
cacheTTL: 0
};
this.group = createSVGElement('g', { class: 'dropped', 'data-type': type });
const rect = createSVGElement('rect', {
x, y,
width: 0,
height: app.componentSize.height,
fill: '#121212',
stroke: '#00ff88',
'stroke-width': 1,
rx: 4, ry: 4
});
this.group.appendChild(rect);
this.text = createSVGElement('text', {
x: x + app.componentSize.width / 2,
y: y + app.componentSize.height / 2 + 5,
'text-anchor': 'middle',
'font-size': 14,
fill: '#ccc'
});
this.text.textContent = this.props.label;
this.app.canvas.appendChild(this.text); // temporarily append to measure
const textWidth = this.text.getBBox().width;
const padding = 20;
const finalWidth = textWidth + padding;
rect.setAttribute('width', finalWidth);
this.text.setAttribute('x', x + finalWidth / 2);
this.group.appendChild(this.text);
this.group.__nodeObj = this;
this.initDrag();
this.group.addEventListener('click', (e) => {
e.stopPropagation();
if (app.arrowMode) {
app.handleConnectionClick(this);
} else {
app.clearSelection();
this.select();
app.showPropsPanel(this);
}
});
app.canvas.appendChild(this.group);
app.placedComponents.push(this);
app.runButton.disabled = false;
}
initDrag() {
this.id = generateNodeId();
let offsetX, offsetY;
const onMouseMove = (e) => {
const pt = this.app.canvas.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(this.app.canvas.getScreenCTM().inverse());
const newX = svgP.x - offsetX;
const newY = svgP.y - offsetY;
this.group.setAttribute('transform', `translate(${newX}, ${newY})`);
this.app.connections.forEach(conn => {
if (conn.start === this || conn.end === this) {
conn.updatePosition();
}
});
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
this.group.addEventListener('mousedown', (e) => {
e.preventDefault();
const pt = this.app.canvas.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(this.app.canvas.getScreenCTM().inverse());
const ctm = this.group.getCTM();
offsetX = svgP.x - ctm.e;
offsetY = svgP.y - ctm.f;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
}
updateLabel(newLabel) {
this.props.label = newLabel;
this.text.textContent = newLabel;
const textWidth = this.text.getBBox().width;
const padding = 20;
const finalWidth = textWidth + padding;
this.group.querySelector('rect').setAttribute('width', finalWidth);
this.text.setAttribute('x', parseFloat(this.group.querySelector('rect').getAttribute('x')) + finalWidth / 2);
}
getCenter() {
const bbox = this.group.getBBox();
const ctm = this.group.getCTM();
const x = ctm.e + bbox.x + bbox.width / 2;
const y = ctm.f + bbox.y + bbox.height / 2;
return { x, y };
}
select() {
this.app.clearSelection();
this.group.classList.add('selected');
this.app.selectedNode = this;
}
deselect() {
this.group.classList.remove('selected');
if (this.app.selectedNode === this) {
this.app.selectedNode = null;
}
}
}
class Connection {
constructor(startNode, endNode, label, app) {
this.start = startNode;
this.end = endNode;
this.app = app;
this.line = createSVGElement('line', {
stroke: '#333', 'stroke-width': 2, 'marker-end': 'url(#arrowhead)'
});
this.text = createSVGElement('text', {
'text-anchor': 'middle', 'font-size': 12, fill: '#333'
});
this.text.textContent = label;
app.canvas.appendChild(this.line);
app.canvas.appendChild(this.text);
this.updatePosition();
this.selected = false;
this.line.addEventListener('click', (e) => {
e.stopPropagation();
this.app.clearSelection();
this.select();
});
}
updatePosition() {
const s = this.start.getCenter();
const e = this.end.getCenter();
this.line.setAttribute('x1', s.x);
this.line.setAttribute('y1', s.y);
this.line.setAttribute('x2', e.x);
this.line.setAttribute('y2', e.y);
const midX = (s.x + e.x) / 2;
const midY = (s.y + e.y) / 2;
this.text.setAttribute('x', midX);
this.text.setAttribute('y', midY - 5);
}
select() {
this.app.clearSelection();
this.selected = true;
this.line.setAttribute('stroke', '#007bff');
this.line.setAttribute('stroke-width', 3);
this.app.selectedConnection = this;
}
deselect() {
this.selected = false;
this.line.setAttribute('stroke', '#333');
this.line.setAttribute('stroke-width', 2);
}
}
class CanvasApp {
constructor() {
this.placedComponents = [];
this.connections = [];
this.componentSize = { width: 120, height: 40 };
this.arrowMode = false;
this.connectionStart = null;
this.activeNode = null;
this.selectedConnection = null;
this.sidebar = document.getElementById('sidebar');
this.arrowTool = document.getElementById('arrow-tool');
this.canvasContainer = document.getElementById('canvas-container');
this.canvas = document.getElementById('canvas');
this.runButton = document.getElementById('run-button');
this.nodePropsPanel = document.getElementById('node-props-panel');
this.propsSaveBtn = document.getElementById('node-props-save');
this.labelGroup = document.getElementById('label-group');
this.dbGroup = document.getElementById('db-group');
this.cacheGroup = document.getElementById('cache-group');
this.selectedNode = null;
this.initEventHandlers();
}
initEventHandlers() {
this.arrowTool.addEventListener('click', () => {
this.arrowMode = !this.arrowMode;
if (this.arrowMode) {
this.arrowTool.classList.add('active');
this.hidePropsPanel();
} else {
this.arrowTool.classList.remove('active');
if (this.connectionStart) {
this.connectionStart.group.classList.remove('selected');
this.connectionStart = null;
}
}
});
this.sidebar.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('component-icon')) {
e.dataTransfer.setData('text/plain', e.target.getAttribute('data-type'));
e.target.classList.add('dragging');
}
});
this.sidebar.addEventListener('dragend', (e) => {
if (e.target.classList.contains('component-icon')) {
e.target.classList.remove('dragging');
}
});
this.canvasContainer.addEventListener('dragover', (e) => e.preventDefault());
this.canvasContainer.addEventListener('drop', (e) => {
e.preventDefault();
const type = e.dataTransfer.getData('text/plain');
const pt = this.canvas.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgP = pt.matrixTransform(this.canvas.getScreenCTM().inverse());
const x = svgP.x - this.componentSize.width / 2;
const y = svgP.y - this.componentSize.height / 2;
new Node(type, x, y, this);
});
this.runButton.addEventListener('click', () => {
const designData = this.exportDesign();
alert(JSON.stringify(designData));
});
this.canvas.addEventListener('click', () => {
if (this.connectionStart) {
this.connectionStart.group.classList.remove('selected');
this.connectionStart = null;
}
this.hidePropsPanel();
this.clearSelection();
});
this.propsSaveBtn.addEventListener('click', () => {
if (!this.activeNode) return;
const nodeObj = this.activeNode;
const panel = this.nodePropsPanel;
const newLabel = panel.querySelector("input[name='label']").value;
nodeObj.updateLabel(newLabel);
if (nodeObj.type === 'Database') {
nodeObj.props.replication = parseInt(panel.querySelector("input[name='replication']").value, 10);
}
if (nodeObj.type === 'CacheStandard' || nodeObj.type === 'CacheLarge') {
nodeObj.props.cacheTTL = parseInt(panel.querySelector("input[name='cacheTTL']").value, 10);
}
this.hidePropsPanel();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
if (this.selectedConnection) {
this.canvas.removeChild(this.selectedConnection.line);
this.canvas.removeChild(this.selectedConnection.text);
const index = this.connections.indexOf(this.selectedConnection);
if (index !== -1) this.connections.splice(index, 1);
this.selectedConnection = null;
} else if (this.selectedNode) {
this.canvas.removeChild(this.selectedNode.group);
this.placedComponents = this.placedComponents.filter(n => n !== this.selectedNode);
this.connections = this.connections.filter(conn => {
if (conn.start === this.selectedNode || conn.end === this.selectedNode) {
this.canvas.removeChild(conn.line);
this.canvas.removeChild(conn.text);
return false;
}
return true;
});
this.selectedNode = null;
this.activeNode = null;
this.hidePropsPanel();
}
}
});
}
handleConnectionClick(nodeObj) {
if (!this.connectionStart) {
this.connectionStart = nodeObj;
nodeObj.group.classList.add('selected');
} else if (this.connectionStart === nodeObj) {
this.connectionStart.group.classList.remove('selected');
this.connectionStart = null;
} else {
const defaultLabel = 'Read traffic';
const label = prompt('Enter connection label:', defaultLabel);
if (label) {
const conn = new Connection(this.connectionStart, nodeObj, label, this);
this.connections.push(conn);
}
this.connectionStart.group.classList.remove('selected');
this.connectionStart = null;
}
}
showPropsPanel(nodeObj) {
// ... unchanged ...
this.activeNode = nodeObj;
const panel = this.nodePropsPanel;
// Position the panel (optional, or you can use fixed top/right)
const bbox = nodeObj.group.getBBox();
const ctm = nodeObj.group.getCTM();
const screenX = ctm.e + bbox.x;
const screenY = ctm.f + bbox.y + bbox.height;
panel.style.left = (screenX + this.canvasContainer.getBoundingClientRect().left) + 'px';
panel.style.top = (screenY + this.canvasContainer.getBoundingClientRect().top) + 'px';
// Always show label group
this.labelGroup.style.display = 'block';
panel.querySelector("input[name='label']").value = nodeObj.props.label;
// Show DB fields if it's a Database
this.dbGroup.style.display = nodeObj.type === 'Database' ? 'block' : 'none';
if (nodeObj.type === 'Database') {
this.dbGroup.querySelector("input[name='replication']").value = nodeObj.props.replication;
}
// Show cache fields if it's a cache
const isCache = nodeObj.type === 'CacheStandard' || nodeObj.type === 'CacheLarge';
this.cacheGroup.style.display = isCache ? 'block' : 'none';
if (isCache) {
this.cacheGroup.querySelector("input[name='cacheTTL']").value = nodeObj.props.cacheTTL;
}
this.propsSaveBtn.disabled = false;
panel.style.display = 'block';
}
hidePropsPanel() {
this.nodePropsPanel.style.display = 'none';
this.propsSaveBtn.disabled = true;
this.activeNode = null;
}
updateConnectionsFor(movedNode) {
this.connections.forEach(conn => {
if (conn.start === movedNode || conn.end === movedNode) {
conn.updatePosition();
}
});
}
clearSelection() {
if (this.selectedConnection) {
this.selectedConnection.deselect();
this.selectedConnection = null;
}
if (this.selectedNode) {
this.selectedNode.deselect();
this.selectedNode = null;
this.hidePropsPanel();
}
}
exportDesign() {
const nodes = this.placedComponents.map(n => ({
id: n.id,
type: n.type,
x: n.x,
y: n.y,
props: n.props
}));
const connections = this.connections.map(c => ({
source: c.start.id,
target: c.end.id,
label: c.label || ''
}));
return { nodes, connections };
}
}
const app = new CanvasApp();
</script>
</body>
</html>