diff --git a/main.go b/main.go
index 38c1c0b..18f4f98 100644
--- a/main.go
+++ b/main.go
@@ -9,12 +9,13 @@ import (
"time"
)
-var tmpl = template.Must(template.ParseFiles("static/index.html"))
+var tmpl = template.Must(template.ParseGlob("static/*.html"))
func main() {
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("/", index)
+ mux.HandleFunc("/game", game)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
@@ -43,5 +44,15 @@ func index(w http.ResponseWriter, r *http.Request) {
Title: "Title",
}
- tmpl.Execute(w, data)
+ tmpl.ExecuteTemplate(w, "index.html", data)
+}
+
+func game(w http.ResponseWriter, r *http.Request) {
+ data := struct {
+ Title string
+ }{
+ Title: "Title",
+ }
+
+ tmpl.ExecuteTemplate(w, "game.html", data)
}
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..a9479ea
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,237 @@
+import { generateNodeId, createSVGElement } from './utils.js';
+import { ComponentNode } from './node.js'
+import { Connection } from './connection.js'
+
+export 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.arrowToolBtn = document.getElementById('arrow-tool-btn');
+ 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.placeholderText = createSVGElement('text', {
+ x: '50%',
+ y: '50%',
+ 'text-anchor': 'middle',
+ 'dominant-baseline': 'middle',
+ fill: '#444',
+ 'font-size': 18,
+ 'pointer-events': 'none'
+ });
+ this.placeholderText.textContent = 'Drag and drop elements to start building your system. Press backspace or delete to remove elements.';
+ this.canvas.appendChild(this.placeholderText);
+ this.initEventHandlers();
+ }
+
+ initEventHandlers() {
+ this.arrowToolBtn.addEventListener('click', () => {
+ this.arrowMode = !this.arrowMode;
+ if (this.arrowMode) {
+ this.arrowToolBtn.classList.add('active');
+ this.hidePropsPanel();
+ } else {
+ this.arrowToolBtn.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 ComponentNode(type, x, y, this);
+ if (this.placeholderText) {
+ this.placeholderText.remove();
+ this.placeholderText = null;
+ }
+ });
+
+ 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) {
+ 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 || '',
+ direction: c.direction
+ }));
+
+ return { nodes, connections };
+ }
+}
diff --git a/static/connection.js b/static/connection.js
new file mode 100644
index 0000000..42dcd2a
--- /dev/null
+++ b/static/connection.js
@@ -0,0 +1,106 @@
+import { generateNodeId, createSVGElement } from './utils.js';
+
+export class Connection {
+ constructor(startNode, endNode, label, app) {
+ this.start = startNode;
+ this.end = endNode;
+ this.app = app;
+ this.label = label;
+ this.direction = "forward";
+ this.line = createSVGElement('line', {
+ stroke: '#ccc', 'stroke-width': 2, 'marker-end': 'url(#arrowhead)'
+ });
+ this.hitbox = createSVGElement('circle', {
+ r: 8,
+ fill: 'transparent',
+ cursor: 'pointer',
+ });
+ this.app.canvas.appendChild(this.hitbox);
+ this.text = createSVGElement('text', {
+ 'text-anchor': 'middle', 'font-size': 12, fill: '#ccc'
+ });
+ 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();
+ });
+
+ this.line.addEventListener('dblclick', (e) => {
+ e.stopPropagation();
+ this.toggleDirection();
+ })
+ }
+
+ updatePosition() {
+ const s = this.start.getConnectionPointToward(this.end);
+ const e = this.end.getConnectionPointToward(this.start);
+
+ this.line.setAttribute('x1', s.x);
+ this.line.setAttribute('y1', s.y);
+ this.line.setAttribute('x2', e.x);
+ this.line.setAttribute('y2', e.y);
+
+ // update text position (midpoint of the line)
+ const midX = (s.x + e.x) / 2;
+ const midY = (s.y + e.y) / 2;
+ this.text.setAttribute('x', midX);
+ this.text.setAttribute('y', midY - 6);
+
+ // update arrowheads based on direction
+ if (this.direction === 'forward') {
+ this.line.setAttribute('marker-start', '');
+ this.line.setAttribute('marker-end', 'url(#arrowhead-end)');
+ } else if (this.direction === 'backward') {
+ this.line.setAttribute('marker-start', 'url(#arrowhead-start)');
+ this.line.setAttribute('marker-end', '');
+ } else if (this.direction === 'bidirectional') {
+ this.line.setAttribute('marker-start', 'url(#arrowhead-start)');
+ this.line.setAttribute('marker-end', 'url(#arrowhead-end)');
+ }
+
+ // Hitbox position (depends on direction)
+ let hbX, hbY;
+ if (this.direction === 'forward') {
+ hbX = e.x;
+ hbY = e.y;
+ } else if (this.direction === 'backward') {
+ hbX = s.x;
+ hbY = s.y;
+ } else {
+ hbX = midX;
+ hbY = midY;
+ }
+
+ this.hitbox.setAttribute('cx', hbX);
+ this.hitbox.setAttribute('cy', hbY);
+ }
+
+ toggleDirection() {
+ console.log('toggle direction')
+ const order = ['forward', 'backward', 'bidirectional'];
+ const currentIndex = order.indexOf(this.direction);
+ const nextIndex = (currentIndex + 1) % order.length;
+ this.direction = order[nextIndex]
+ this.updatePosition();
+ }
+
+ 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);
+ }
+}
diff --git a/static/game.html b/static/game.html
new file mode 100644
index 0000000..05ec6b5
--- /dev/null
+++ b/static/game.html
@@ -0,0 +1,681 @@
+
+
+
+
+
+ System Design Game
+
+
+
+
+
+
+
+
+
+
+ -
+
Url Shortener
+ Easy
+
+ -
+
Url Shortener
+ Easy
+
+ -
+
Url Shortener
+ Medium
+
+ -
+
Something hard
+ Hard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Functional Requirements
+
+ - Something
+ - Something else
+
+
+
+
+
Non-Functional Requirements
+
+ - Something
+ - Something else
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
level constraints
+
🎯 target rps: –
+
⏱️ max p95 latency: –
+
💸 max cost: –
+
🔒 availability: –
+
+
+
+
simulation results
+
✅ cost: –
+
⚡ p95 latency: –
+
📈 achieved rps: –
+
🛡️ availability: –
+
+
+
+
+
+
node properties
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This is Tab 3 content.
+
+
+
+
+
+
+
+
+
diff --git a/static/index.js b/static/index.js
new file mode 100644
index 0000000..c494d66
--- /dev/null
+++ b/static/index.js
@@ -0,0 +1,5 @@
+import { CanvasApp } from './app.js';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new CanvasApp();
+});
diff --git a/static/node.js b/static/node.js
new file mode 100644
index 0000000..f63055e
--- /dev/null
+++ b/static/node.js
@@ -0,0 +1,161 @@
+import { generateNodeId, createSVGElement } from './utils.js';
+
+export class ComponentNode {
+ 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': 16,
+ 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();
+ }
+ });
+ this.group.addEventListener('dblclick', (e) => {
+ e.stopPropagation();
+ if (!app.arrowMode) {
+ 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;
+ }
+ }
+
+ getConnectionPointToward(otherNode) {
+ const bbox = this.group.getBBox();
+ const ctm = this.group.getCTM();
+
+ const centerX = ctm.e + bbox.x + bbox.width / 2;
+ const centerY = ctm.f + bbox.y + bbox.height / 2;
+
+ const otherCenter = otherNode.getCenter();
+
+ let edgeX = centerX;
+ let edgeY = centerY;
+
+ const dx = otherCenter.x - centerX;
+ const dy = otherCenter.y - centerY;
+
+ if (Math.abs(dx) > Math.abs(dy)) {
+ edgeX += dx > 0 ? bbox.width / 2 : -bbox.width / 2;
+ } else {
+ edgeY += dy > 0 ? bbox.height / 2 : -bbox.height / 2;
+ }
+
+ return { x: edgeX, y: edgeY };
+ }
+}
diff --git a/static/utils.js b/static/utils.js
new file mode 100644
index 0000000..4c74f08
--- /dev/null
+++ b/static/utils.js
@@ -0,0 +1,23 @@
+let nodeIdCounter = 1;
+
+/**
+ * Generates a unique node ID like "node-1", "node-2", etc.
+ * @returns {string}
+ */
+export function generateNodeId() {
+ return `node-${nodeIdCounter++}`;
+}
+
+/**
+ * Creates an SVG element with the given tag and attributes.
+ * @param {string} tag - The SVG tag name (e.g., 'rect', 'line').
+ * @param {Object} attrs - An object of attribute key-value pairs.
+ * @returns {SVGElement}
+ */
+export 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;
+}