|
|
<!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; |
|
|
height: 100vh; |
|
|
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: 100%; |
|
|
background: #121212; |
|
|
border: 2px dashed #30363d; |
|
|
border-radius: 8px; |
|
|
} |
|
|
|
|
|
.dropped { |
|
|
cursor: move; |
|
|
} |
|
|
|
|
|
.dropped.selected rect { |
|
|
stroke: #00bcd4; |
|
|
stroke-width: 2; |
|
|
} |
|
|
|
|
|
#run-button { |
|
|
margin-top: auto; |
|
|
padding: 10px; |
|
|
background-color: #007acc; |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 4px; |
|
|
cursor: pointer; |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
#run-button:disabled { |
|
|
background-color: #555; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
|
|
|
#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; |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="challenge-container"> |
|
|
Challenges |
|
|
</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">This is Tab 1 content.</div> |
|
|
|
|
|
<!-- Design--> |
|
|
<div id="content2" class="tab-content"> |
|
|
<div id="sidebar"> |
|
|
<div class="component-icon" draggable="true" data-type="client"> |
|
|
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="webserversmall"> |
|
|
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="webservermedium"> |
|
|
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> |
|
|
<button id="run-button" disabled>run simulation</button> |
|
|
</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> |
|
|
|
|
|
</div> |
|
|
|
|
|
<!-- Metrics--> |
|
|
<div id="content3" class="tab-content">This is Tab 3 content.</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> |
|
|
|
|
|
|