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 + + + +
+
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; +}