mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 08:47:42 +00:00
954 lines
30 KiB
HTML
954 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Database ERD — Student Progress Tracker</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #f5f7fb;
|
||
--surface: #ffffff;
|
||
--surface-hover: #f0f2f7;
|
||
--border: #e2e4ed;
|
||
--border-highlight: #b0b6d0;
|
||
--text: #374151;
|
||
--text-muted: #9ca3af;
|
||
--text-bright: #111827;
|
||
--pk: #b45309;
|
||
--pk-bg: rgba(180, 83, 9, 0.08);
|
||
--fk: #1d4ed8;
|
||
--fk-bg: rgba(29, 78, 216, 0.08);
|
||
--col: #4b5563;
|
||
--type: #9ca3af;
|
||
--line-color: #94a3b8;
|
||
--line-highlight: #2563eb;
|
||
--accent-green: #16a34a;
|
||
--accent-red: #dc2626;
|
||
--accent-purple: #7c3aed;
|
||
--shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||
--shadow-lg: 0 4px 24px rgba(0,0,0,0.14);
|
||
--radius: 10px;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: 'DM Sans', sans-serif;
|
||
overflow: hidden;
|
||
height: 100vh;
|
||
width: 100vw;
|
||
}
|
||
|
||
#toolbar {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0;
|
||
height: 52px;
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 20px;
|
||
z-index: 1000;
|
||
gap: 16px;
|
||
}
|
||
|
||
#toolbar h1 {
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-bright);
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
#toolbar .separator {
|
||
width: 1px;
|
||
height: 24px;
|
||
background: var(--border);
|
||
}
|
||
|
||
#toolbar .stat {
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
#toolbar .stat span { color: var(--text); font-weight: 600; }
|
||
|
||
.toolbar-actions {
|
||
margin-left: auto;
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
background: var(--border);
|
||
border: none;
|
||
color: var(--text);
|
||
font-family: 'DM Sans', sans-serif;
|
||
font-size: 12px;
|
||
padding: 6px 14px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.toolbar-btn:hover { background: var(--border-highlight); color: var(--text-bright); }
|
||
|
||
#canvas-container {
|
||
position: absolute;
|
||
top: 52px; left: 0; right: 0; bottom: 0;
|
||
overflow: hidden;
|
||
cursor: grab;
|
||
}
|
||
#canvas-container:active { cursor: grabbing; }
|
||
|
||
#canvas {
|
||
position: absolute;
|
||
transform-origin: 0 0;
|
||
}
|
||
|
||
svg#lines {
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
pointer-events: none;
|
||
overflow: visible;
|
||
}
|
||
|
||
.table-node {
|
||
position: absolute;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
min-width: 240px;
|
||
box-shadow: var(--shadow);
|
||
cursor: move;
|
||
user-select: none;
|
||
transition: box-shadow 0.2s, border-color 0.2s;
|
||
}
|
||
.table-node:hover {
|
||
border-color: var(--border-highlight);
|
||
box-shadow: var(--shadow-lg);
|
||
z-index: 10;
|
||
}
|
||
.table-node.dragging {
|
||
z-index: 100;
|
||
border-color: var(--fk);
|
||
box-shadow: 0 0 0 1px var(--fk), var(--shadow-lg);
|
||
}
|
||
|
||
.table-header {
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.table-icon {
|
||
width: 20px; height: 20px;
|
||
border-radius: 5px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.table-header .table-name {
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text-bright);
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.table-body { padding: 4px 0; }
|
||
|
||
.column-row {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 3px 14px;
|
||
gap: 8px;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 11.5px;
|
||
line-height: 1.6;
|
||
transition: background 0.1s;
|
||
}
|
||
.column-row:hover { background: var(--surface-hover); }
|
||
|
||
.col-badge {
|
||
font-size: 8px;
|
||
font-weight: 700;
|
||
padding: 1px 4px;
|
||
border-radius: 3px;
|
||
letter-spacing: 0.05em;
|
||
min-width: 20px;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.col-badge.pk { background: var(--pk-bg); color: var(--pk); }
|
||
.col-badge.fk { background: var(--fk-bg); color: var(--fk); }
|
||
.col-badge.empty { min-width: 20px; }
|
||
|
||
.col-name { color: var(--col); flex: 1; }
|
||
.col-name.pk-name { color: var(--pk); }
|
||
.col-name.fk-name { color: var(--fk); }
|
||
|
||
.col-type { color: var(--type); font-size: 10px; text-align: right; }
|
||
|
||
/* Legend */
|
||
#legend {
|
||
position: fixed;
|
||
bottom: 16px; left: 16px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 12px 16px;
|
||
z-index: 1000;
|
||
font-size: 11px;
|
||
display: flex;
|
||
gap: 16px;
|
||
}
|
||
.legend-item { display: flex; align-items: center; gap: 6px; color: var(--text-muted); }
|
||
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
|
||
|
||
/* Zoom controls */
|
||
#zoom-controls {
|
||
position: fixed;
|
||
bottom: 16px; right: 16px;
|
||
display: flex;
|
||
gap: 4px;
|
||
z-index: 1000;
|
||
}
|
||
#zoom-controls button {
|
||
width: 36px; height: 36px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text);
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s;
|
||
}
|
||
#zoom-controls button:hover { background: var(--surface-hover); border-color: var(--border-highlight); }
|
||
|
||
/* Minimap */
|
||
#minimap {
|
||
position: fixed;
|
||
bottom: 60px; right: 16px;
|
||
width: 180px; height: 120px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
z-index: 1000;
|
||
overflow: hidden;
|
||
}
|
||
#minimap canvas { width: 100%; height: 100%; }
|
||
|
||
/* Relationship line styles */
|
||
.rel-line {
|
||
fill: none;
|
||
stroke: var(--line-color);
|
||
stroke-width: 1.5;
|
||
opacity: 0.5;
|
||
transition: opacity 0.2s, stroke 0.2s;
|
||
}
|
||
.rel-line.highlighted {
|
||
stroke: var(--line-highlight);
|
||
opacity: 1;
|
||
stroke-width: 2;
|
||
}
|
||
.rel-marker {
|
||
fill: var(--line-color);
|
||
transition: fill 0.2s;
|
||
}
|
||
.rel-marker.highlighted { fill: var(--line-highlight); }
|
||
|
||
.rel-label {
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 9px;
|
||
fill: var(--text-muted);
|
||
pointer-events: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="toolbar">
|
||
<h1>⬡ ERD</h1>
|
||
<div class="separator"></div>
|
||
<div class="stat">Tables <span id="table-count">17</span></div>
|
||
<div class="separator"></div>
|
||
<div class="stat">Relations <span id="rel-count">0</span></div>
|
||
<div class="toolbar-actions">
|
||
<button class="toolbar-btn" onclick="resetView()">Reset View</button>
|
||
<button class="toolbar-btn" onclick="toggleLabels()">Toggle Labels</button>
|
||
<a href="technical.html" class="toolbar-btn" style="text-decoration:none;">← Technical Docs</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="canvas-container">
|
||
<div id="canvas">
|
||
<svg id="lines" width="6000" height="4000"></svg>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="legend">
|
||
<div class="legend-item"><div class="legend-dot" style="background:var(--pk)"></div> Primary Key</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:var(--fk)"></div> Foreign Key</div>
|
||
<div class="legend-item">
|
||
<svg width="30" height="10"><line x1="0" y1="5" x2="30" y2="5" stroke="var(--line-color)" stroke-width="1.5"/></svg>
|
||
Relationship
|
||
</div>
|
||
</div>
|
||
|
||
<div id="zoom-controls">
|
||
<button onclick="zoomIn()">+</button>
|
||
<button onclick="zoomOut()">−</button>
|
||
<button onclick="resetView()" style="font-size:12px">⌂</button>
|
||
</div>
|
||
|
||
<div id="minimap"><canvas id="minimap-canvas"></canvas></div>
|
||
|
||
<script>
|
||
// ─── Schema Data ───
|
||
const schema = {
|
||
tables: {
|
||
user: {
|
||
columns: [
|
||
{ name: 'id_user', type: 'char(36)', pk: true },
|
||
{ name: 'email', type: 'varchar(255)' },
|
||
{ name: 'name', type: 'varchar(255)' },
|
||
{ name: 'password_hash', type: 'varchar(255)' },
|
||
{ name: 'password_salt', type: 'varchar(255)' },
|
||
{ name: 'password_updated_at', type: 'timestamp' },
|
||
{ name: 'failed_login_attempts', type: 'int' },
|
||
{ name: 'locked_until', type: 'timestamp' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#6ea8fe'
|
||
},
|
||
student: {
|
||
columns: [
|
||
{ name: 'id_student', type: 'char(36)', pk: true },
|
||
{ name: 'id_program', type: 'char(36)', fk: 'program' },
|
||
{ name: 'identifier', type: 'varchar(50)' },
|
||
{ name: 'program_year', type: 'int' },
|
||
{ name: 'enrollment_date', type: 'date' },
|
||
{ name: 'next_iep_date', type: 'date' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#66d9a0'
|
||
},
|
||
goal: {
|
||
columns: [
|
||
{ name: 'id_goal', type: 'char(36)', pk: true },
|
||
{ name: 'id_goal_parent', type: 'char(36)', fk: 'goal' },
|
||
{ name: 'id_student', type: 'char(36)', fk: 'student' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'description', type: 'text' },
|
||
{ name: 'category', type: 'varchar(255)' },
|
||
{ name: 'baseline', type: 'text' },
|
||
{ name: 'target_completion_date', type: 'date' },
|
||
{ name: 'close_date', type: 'date' },
|
||
{ name: 'achieved', type: 'tinyint(1)' },
|
||
{ name: 'close_notes', type: 'text' },
|
||
{ name: 'created_at', type: 'timestamp' },
|
||
{ name: 'updated_at', type: 'timestamp' }
|
||
],
|
||
color: '#f0b866'
|
||
},
|
||
benchmark: {
|
||
columns: [
|
||
{ name: 'id_benchmark', type: 'char(36)', pk: true },
|
||
{ name: 'id_goal', type: 'char(36)', fk: 'goal' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'benchmark', type: 'text' },
|
||
{ name: 'short_name', type: 'varchar(50)' },
|
||
{ name: 'created_at', type: 'datetime' },
|
||
{ name: 'updated_at', type: 'datetime' }
|
||
],
|
||
color: '#f0b866'
|
||
},
|
||
progress_event: {
|
||
columns: [
|
||
{ name: 'id_progress_event', type: 'char(36)', pk: true },
|
||
{ name: 'id_goal', type: 'char(36)', fk: 'goal' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'content', type: 'text' },
|
||
{ name: 'is_sensitive', type: 'tinyint(1)' },
|
||
{ name: 'created_at', type: 'timestamp' },
|
||
{ name: 'updated_at', type: 'timestamp' }
|
||
],
|
||
color: '#c792ea'
|
||
},
|
||
progress_event_benchmark: {
|
||
columns: [
|
||
{ name: 'id_progress_event_benchmark', type: 'char(36)', pk: true },
|
||
{ name: 'id_progress_event', type: 'char(36)', fk: 'progress_event' },
|
||
{ name: 'id_benchmark', type: 'char(36)', fk: 'benchmark' },
|
||
{ name: 'created_at', type: 'datetime' }
|
||
],
|
||
color: '#c792ea'
|
||
},
|
||
progress_report: {
|
||
columns: [
|
||
{ name: 'id_progress_report', type: 'char(36)', pk: true },
|
||
{ name: 'id_student', type: 'char(36)', fk: 'student' },
|
||
{ name: 'id_goal', type: 'char(36)', fk: 'goal' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'period', type: 'varchar(10)' },
|
||
{ name: 'year', type: 'int' },
|
||
{ name: 'summary', type: 'text' },
|
||
{ name: 'generated_at', type: 'timestamp' }
|
||
],
|
||
color: '#c792ea'
|
||
},
|
||
health_note: {
|
||
columns: [
|
||
{ name: 'id_health_note', type: 'char(36)', pk: true },
|
||
{ name: 'id_student', type: 'char(36)', fk: 'student' },
|
||
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
|
||
{ name: 'content', type: 'text' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#66d9a0'
|
||
},
|
||
program: {
|
||
columns: [
|
||
{ name: 'id_program', type: 'char(36)', pk: true },
|
||
{ name: 'id_school_district', type: 'char(36)', fk: 'school_district' },
|
||
{ name: 'name', type: 'varchar(255)' },
|
||
{ name: 'description', type: 'text' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#f07178'
|
||
},
|
||
school_district: {
|
||
columns: [
|
||
{ name: 'id_school_district', type: 'char(36)', pk: true },
|
||
{ name: 'name', type: 'varchar(255)' },
|
||
{ name: 'contact_email', type: 'varchar(255)' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#f07178'
|
||
},
|
||
role: {
|
||
columns: [
|
||
{ name: 'id_role', type: 'char(36)', pk: true },
|
||
{ name: 'name', type: 'varchar(100)' },
|
||
{ name: 'internal_name', type: 'varchar(100)' },
|
||
{ name: 'description', type: 'text' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#6ea8fe'
|
||
},
|
||
user_program: {
|
||
columns: [
|
||
{ name: 'id_user_program', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'id_program', type: 'char(36)', fk: 'program' },
|
||
{ name: 'id_role', type: 'char(36)', fk: 'role' },
|
||
{ name: 'is_primary', type: 'tinyint(1)' },
|
||
{ name: 'status', type: 'varchar(20)' },
|
||
{ name: 'joined_at', type: 'timestamp' }
|
||
],
|
||
color: '#6ea8fe'
|
||
},
|
||
user_student: {
|
||
columns: [
|
||
{ name: 'id_user_student', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'id_student', type: 'char(36)', fk: 'student' },
|
||
{ name: 'is_primary', type: 'tinyint(1)' }
|
||
],
|
||
color: '#6ea8fe'
|
||
},
|
||
password_history: {
|
||
columns: [
|
||
{ name: 'id_password_history', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'password_hash', type: 'varchar(255)' },
|
||
{ name: 'created_at', type: 'timestamp' }
|
||
],
|
||
color: '#5a5f75'
|
||
},
|
||
password_reset_token: {
|
||
columns: [
|
||
{ name: 'id_password_reset_token', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'token_hash', type: 'varchar(255)' },
|
||
{ name: 'expires_at', type: 'timestamp' },
|
||
{ name: 'created_at', type: 'timestamp' },
|
||
{ name: 'used_at', type: 'timestamp' },
|
||
{ name: 'invalidated_at', type: 'timestamp' },
|
||
{ name: 'request_ip', type: 'varchar(45)' }
|
||
],
|
||
color: '#5a5f75'
|
||
},
|
||
refresh_token: {
|
||
columns: [
|
||
{ name: 'id_refresh_token', type: 'char(36)', pk: true },
|
||
{ name: 'id_user', type: 'char(36)', fk: 'user' },
|
||
{ name: 'id_program', type: 'char(36)', fk: 'program' },
|
||
{ name: 'token_hash', type: 'varchar(512)' },
|
||
{ name: 'token_salt', type: 'varchar(512)' },
|
||
{ name: 'expires_at', type: 'timestamp' },
|
||
{ name: 'last_used_at', type: 'timestamp' },
|
||
{ name: 'revoked_at', type: 'timestamp' },
|
||
{ name: 'device_info', type: 'varchar(255)' },
|
||
{ name: 'user_agent', type: 'varchar(512)' },
|
||
{ name: 'replaced_by_token_id', type: 'char(36)', fk: 'refresh_token' },
|
||
{ name: 'created_at', type: 'timestamp' },
|
||
{ name: 'updated_at', type: 'timestamp' }
|
||
],
|
||
color: '#5a5f75'
|
||
},
|
||
ReportPrompt: {
|
||
columns: [
|
||
{ name: 'id_ReportPrompt', type: 'char(36)', pk: true },
|
||
{ name: 'prompt', type: 'text' },
|
||
{ name: 'reportname', type: 'char(100)' },
|
||
{ name: 'id_program', type: 'char(36)', fk: 'program' }
|
||
],
|
||
color: '#c792ea'
|
||
}
|
||
}
|
||
};
|
||
|
||
// Explicit foreign key relationships from the SQL constraints
|
||
const relationships = [
|
||
{ from: 'goal', fromCol: 'id_goal_parent', to: 'goal', toCol: 'id_goal', label: 'parent' },
|
||
{ from: 'goal', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
|
||
{ from: 'goal', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'health_note', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
|
||
{ from: 'health_note', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'password_history', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'password_reset_token', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'program', fromCol: 'id_school_district', to: 'school_district', toCol: 'id_school_district' },
|
||
{ from: 'progress_event', fromCol: 'id_goal', to: 'goal', toCol: 'id_goal' },
|
||
{ from: 'progress_event', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'progress_event_benchmark', fromCol: 'id_progress_event', to: 'progress_event', toCol: 'id_progress_event' },
|
||
{ from: 'progress_event_benchmark', fromCol: 'id_benchmark', to: 'benchmark', toCol: 'id_benchmark' },
|
||
{ from: 'progress_report', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
|
||
{ from: 'progress_report', fromCol: 'id_goal', to: 'goal', toCol: 'id_goal' },
|
||
{ from: 'progress_report', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'refresh_token', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'refresh_token', fromCol: 'id_program', to: 'program', toCol: 'id_program' },
|
||
{ from: 'refresh_token', fromCol: 'replaced_by_token_id', to: 'refresh_token', toCol: 'id_refresh_token', label: 'replaces' },
|
||
{ from: 'benchmark', fromCol: 'id_goal', to: 'goal', toCol: 'id_goal' },
|
||
{ from: 'benchmark', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
|
||
{ from: 'student', fromCol: 'id_program', to: 'program', toCol: 'id_program' },
|
||
{ from: 'user_program', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'user_program', fromCol: 'id_program', to: 'program', toCol: 'id_program' },
|
||
{ from: 'user_program', fromCol: 'id_role', to: 'role', toCol: 'id_role' },
|
||
{ from: 'user_student', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
|
||
{ from: 'user_student', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
|
||
{ from: 'ReportPrompt', fromCol: 'id_program', to: 'program', toCol: 'id_program' }
|
||
];
|
||
|
||
document.getElementById('rel-count').textContent = relationships.length;
|
||
|
||
// ─── Layout positions (hand-tuned for clarity) ───
|
||
const positions = {
|
||
// Core cluster
|
||
user: { x: 100, y: 300 },
|
||
role: { x: 100, y: 50 },
|
||
user_program: { x: 380, y: 80 },
|
||
user_student: { x: 380, y: 340 },
|
||
// Org cluster
|
||
school_district: { x: 680, y: 50 },
|
||
program: { x: 680, y: 260 },
|
||
student: { x: 680, y: 500 },
|
||
// Goals & progress cluster
|
||
goal: { x: 1050, y: 260 },
|
||
benchmark: { x: 1050, y: 50 },
|
||
progress_event: { x: 1380, y: 100 },
|
||
progress_event_benchmark: { x: 1380, y: 340 },
|
||
progress_report: { x: 1050, y: 570 },
|
||
health_note: { x: 680, y: 760 },
|
||
ReportPrompt: { x: 1380, y: 570 },
|
||
// Auth cluster
|
||
password_history: { x: 100, y: 620 },
|
||
password_reset_token: { x: 100, y: 800 },
|
||
refresh_token: { x: 380, y: 620 }
|
||
};
|
||
|
||
// ─── Rendering ───
|
||
const canvas = document.getElementById('canvas');
|
||
const svgLines = document.getElementById('lines');
|
||
const container = document.getElementById('canvas-container');
|
||
const tableElements = {};
|
||
let scale = 0.85;
|
||
let panX = 50, panY = 20;
|
||
let showLabels = true;
|
||
|
||
function renderTables() {
|
||
for (const [tableName, table] of Object.entries(schema.tables)) {
|
||
const pos = positions[tableName] || { x: 0, y: 0 };
|
||
const el = document.createElement('div');
|
||
el.className = 'table-node';
|
||
el.id = `table-${tableName}`;
|
||
el.style.left = pos.x + 'px';
|
||
el.style.top = pos.y + 'px';
|
||
|
||
const headerColor = table.color || '#6ea8fe';
|
||
let html = `
|
||
<div class="table-header">
|
||
<div class="table-icon" style="background:${headerColor}22;color:${headerColor}">⬡</div>
|
||
<span class="table-name">${tableName}</span>
|
||
</div>
|
||
<div class="table-body">
|
||
`;
|
||
for (const col of table.columns) {
|
||
const isPk = col.pk;
|
||
const isFk = col.fk;
|
||
let badge = '<span class="col-badge empty"></span>';
|
||
if (isPk) badge = '<span class="col-badge pk">PK</span>';
|
||
else if (isFk) badge = '<span class="col-badge fk">FK</span>';
|
||
|
||
let nameClass = 'col-name';
|
||
if (isPk) nameClass += ' pk-name';
|
||
else if (isFk) nameClass += ' fk-name';
|
||
|
||
html += `
|
||
<div class="column-row" data-col="${col.name}">
|
||
${badge}
|
||
<span class="${nameClass}">${col.name}</span>
|
||
<span class="col-type">${col.type}</span>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
canvas.appendChild(el);
|
||
tableElements[tableName] = el;
|
||
makeDraggable(el, tableName);
|
||
}
|
||
}
|
||
|
||
function getColumnY(tableName, colName) {
|
||
const el = tableElements[tableName];
|
||
if (!el) return 0;
|
||
const row = el.querySelector(`[data-col="${colName}"]`);
|
||
if (!row) return el.offsetTop + 20;
|
||
return el.offsetTop + row.offsetTop + row.offsetHeight / 2;
|
||
}
|
||
|
||
function getTableCenter(tableName) {
|
||
const el = tableElements[tableName];
|
||
if (!el) return { x: 0, y: 0 };
|
||
return {
|
||
x: el.offsetLeft + el.offsetWidth / 2,
|
||
y: el.offsetTop + el.offsetHeight / 2
|
||
};
|
||
}
|
||
|
||
function drawRelationships() {
|
||
svgLines.innerHTML = '';
|
||
|
||
// Arrow marker defs
|
||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||
|
||
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||
marker.setAttribute('id', 'arrowhead');
|
||
marker.setAttribute('markerWidth', '8');
|
||
marker.setAttribute('markerHeight', '6');
|
||
marker.setAttribute('refX', '8');
|
||
marker.setAttribute('refY', '3');
|
||
marker.setAttribute('orient', 'auto');
|
||
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||
poly.setAttribute('points', '0 0, 8 3, 0 6');
|
||
poly.setAttribute('class', 'rel-marker');
|
||
marker.appendChild(poly);
|
||
defs.appendChild(marker);
|
||
|
||
const markerH = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||
markerH.setAttribute('id', 'arrowhead-hl');
|
||
markerH.setAttribute('markerWidth', '8');
|
||
markerH.setAttribute('markerHeight', '6');
|
||
markerH.setAttribute('refX', '8');
|
||
markerH.setAttribute('refY', '3');
|
||
markerH.setAttribute('orient', 'auto');
|
||
const polyH = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||
polyH.setAttribute('points', '0 0, 8 3, 0 6');
|
||
polyH.setAttribute('class', 'rel-marker highlighted');
|
||
markerH.appendChild(polyH);
|
||
defs.appendChild(markerH);
|
||
|
||
svgLines.appendChild(defs);
|
||
|
||
for (const rel of relationships) {
|
||
const fromEl = tableElements[rel.from];
|
||
const toEl = tableElements[rel.to];
|
||
if (!fromEl || !toEl) continue;
|
||
|
||
const fromY = getColumnY(rel.from, rel.fromCol);
|
||
const toY = getColumnY(rel.to, rel.toCol);
|
||
|
||
const fromLeft = fromEl.offsetLeft;
|
||
const fromRight = fromEl.offsetLeft + fromEl.offsetWidth;
|
||
const toLeft = toEl.offsetLeft;
|
||
const toRight = toEl.offsetLeft + toEl.offsetWidth;
|
||
|
||
let x1, x2;
|
||
// Self-referencing
|
||
if (rel.from === rel.to) {
|
||
x1 = fromRight;
|
||
x2 = fromRight;
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
const loopSize = 40;
|
||
const d = `M ${x1} ${fromY} C ${x1 + loopSize} ${fromY - loopSize}, ${x2 + loopSize} ${toY + loopSize}, ${x2} ${toY}`;
|
||
path.setAttribute('d', d);
|
||
path.setAttribute('class', 'rel-line');
|
||
path.setAttribute('marker-end', 'url(#arrowhead)');
|
||
path.dataset.from = rel.from;
|
||
path.dataset.to = rel.to;
|
||
svgLines.appendChild(path);
|
||
|
||
if (showLabels && rel.label) {
|
||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
text.setAttribute('x', x1 + loopSize + 4);
|
||
text.setAttribute('y', (fromY + toY) / 2);
|
||
text.setAttribute('class', 'rel-label');
|
||
text.textContent = rel.label;
|
||
svgLines.appendChild(text);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Determine which side to connect
|
||
const fromCX = (fromLeft + fromRight) / 2;
|
||
const toCX = (toLeft + toRight) / 2;
|
||
|
||
if (fromCX < toCX) {
|
||
x1 = fromRight;
|
||
x2 = toLeft;
|
||
} else {
|
||
x1 = fromLeft;
|
||
x2 = toRight;
|
||
}
|
||
|
||
const dx = Math.abs(x2 - x1);
|
||
const cpOffset = Math.max(40, dx * 0.35);
|
||
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
const cp1x = x1 < x2 ? x1 + cpOffset : x1 - cpOffset;
|
||
const cp2x = x1 < x2 ? x2 - cpOffset : x2 + cpOffset;
|
||
const d = `M ${x1} ${fromY} C ${cp1x} ${fromY}, ${cp2x} ${toY}, ${x2} ${toY}`;
|
||
path.setAttribute('d', d);
|
||
path.setAttribute('class', 'rel-line');
|
||
path.setAttribute('marker-end', 'url(#arrowhead)');
|
||
path.dataset.from = rel.from;
|
||
path.dataset.to = rel.to;
|
||
svgLines.appendChild(path);
|
||
|
||
if (showLabels && rel.label) {
|
||
const mx = (x1 + x2) / 2;
|
||
const my = (fromY + toY) / 2 - 6;
|
||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
text.setAttribute('x', mx);
|
||
text.setAttribute('y', my);
|
||
text.setAttribute('class', 'rel-label');
|
||
text.setAttribute('text-anchor', 'middle');
|
||
text.textContent = rel.label;
|
||
svgLines.appendChild(text);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Interaction: Drag ───
|
||
function makeDraggable(el, tableName) {
|
||
let startX, startY, origX, origY;
|
||
|
||
el.addEventListener('mousedown', (e) => {
|
||
if (e.button !== 0) return;
|
||
e.stopPropagation();
|
||
el.classList.add('dragging');
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
origX = el.offsetLeft;
|
||
origY = el.offsetTop;
|
||
|
||
function onMove(e) {
|
||
const dx = (e.clientX - startX) / scale;
|
||
const dy = (e.clientY - startY) / scale;
|
||
el.style.left = (origX + dx) + 'px';
|
||
el.style.top = (origY + dy) + 'px';
|
||
positions[tableName].x = origX + dx;
|
||
positions[tableName].y = origY + dy;
|
||
drawRelationships();
|
||
updateMinimap();
|
||
}
|
||
|
||
function onUp() {
|
||
el.classList.remove('dragging');
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
|
||
// Hover highlighting
|
||
el.addEventListener('mouseenter', () => highlightTable(tableName));
|
||
el.addEventListener('mouseleave', () => clearHighlights());
|
||
}
|
||
|
||
function highlightTable(tableName) {
|
||
const lines = svgLines.querySelectorAll('.rel-line');
|
||
lines.forEach(line => {
|
||
if (line.dataset.from === tableName || line.dataset.to === tableName) {
|
||
line.classList.add('highlighted');
|
||
line.setAttribute('marker-end', 'url(#arrowhead-hl)');
|
||
} else {
|
||
line.style.opacity = '0.15';
|
||
}
|
||
});
|
||
}
|
||
|
||
function clearHighlights() {
|
||
const lines = svgLines.querySelectorAll('.rel-line');
|
||
lines.forEach(line => {
|
||
line.classList.remove('highlighted');
|
||
line.setAttribute('marker-end', 'url(#arrowhead)');
|
||
line.style.opacity = '';
|
||
});
|
||
}
|
||
|
||
// ─── Pan & Zoom ───
|
||
let isPanning = false, panStartX, panStartY;
|
||
|
||
container.addEventListener('mousedown', (e) => {
|
||
if (e.target !== container && e.target !== canvas && e.target.tagName !== 'svg') return;
|
||
isPanning = true;
|
||
panStartX = e.clientX - panX;
|
||
panStartY = e.clientY - panY;
|
||
container.style.cursor = 'grabbing';
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isPanning) return;
|
||
panX = e.clientX - panStartX;
|
||
panY = e.clientY - panStartY;
|
||
applyTransform();
|
||
updateMinimap();
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
isPanning = false;
|
||
container.style.cursor = 'grab';
|
||
});
|
||
|
||
container.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
||
const newScale = Math.max(0.2, Math.min(2, scale * delta));
|
||
|
||
// Zoom toward cursor
|
||
const rect = container.getBoundingClientRect();
|
||
const cx = e.clientX - rect.left;
|
||
const cy = e.clientY - rect.top;
|
||
panX = cx - (cx - panX) * (newScale / scale);
|
||
panY = cy - (cy - panY) * (newScale / scale);
|
||
|
||
scale = newScale;
|
||
applyTransform();
|
||
updateMinimap();
|
||
}, { passive: false });
|
||
|
||
function applyTransform() {
|
||
canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
||
}
|
||
|
||
function zoomIn() { scale = Math.min(2, scale * 1.2); applyTransform(); updateMinimap(); }
|
||
function zoomOut() { scale = Math.max(0.2, scale * 0.8); applyTransform(); updateMinimap(); }
|
||
|
||
function resetView() {
|
||
scale = 0.85;
|
||
panX = 50;
|
||
panY = 20;
|
||
applyTransform();
|
||
updateMinimap();
|
||
}
|
||
|
||
function toggleLabels() {
|
||
showLabels = !showLabels;
|
||
drawRelationships();
|
||
}
|
||
|
||
// ─── Minimap ───
|
||
const minimapCanvas = document.getElementById('minimap-canvas');
|
||
const mCtx = minimapCanvas.getContext('2d');
|
||
|
||
function updateMinimap() {
|
||
const mw = 180, mh = 120;
|
||
minimapCanvas.width = mw * 2;
|
||
minimapCanvas.height = mh * 2;
|
||
minimapCanvas.style.width = mw + 'px';
|
||
minimapCanvas.style.height = mh + 'px';
|
||
mCtx.scale(2, 2);
|
||
|
||
mCtx.clearRect(0, 0, mw, mh);
|
||
mCtx.fillStyle = '#0f1117';
|
||
mCtx.fillRect(0, 0, mw, mh);
|
||
|
||
// Find bounds
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const [name, pos] of Object.entries(positions)) {
|
||
const el = tableElements[name];
|
||
if (!el) continue;
|
||
minX = Math.min(minX, pos.x);
|
||
minY = Math.min(minY, pos.y);
|
||
maxX = Math.max(maxX, pos.x + el.offsetWidth);
|
||
maxY = Math.max(maxY, pos.y + el.offsetHeight);
|
||
}
|
||
|
||
const padding = 40;
|
||
minX -= padding; minY -= padding;
|
||
maxX += padding; maxY += padding;
|
||
const scaleX = mw / (maxX - minX);
|
||
const scaleY = mh / (maxY - minY);
|
||
const ms = Math.min(scaleX, scaleY);
|
||
|
||
// Draw tables
|
||
for (const [name, pos] of Object.entries(positions)) {
|
||
const el = tableElements[name];
|
||
if (!el) continue;
|
||
const color = schema.tables[name]?.color || '#6ea8fe';
|
||
mCtx.fillStyle = color + '60';
|
||
mCtx.fillRect(
|
||
(pos.x - minX) * ms,
|
||
(pos.y - minY) * ms,
|
||
el.offsetWidth * ms,
|
||
el.offsetHeight * ms
|
||
);
|
||
}
|
||
|
||
// Draw viewport
|
||
const rect = container.getBoundingClientRect();
|
||
const vx = (-panX / scale - minX) * ms;
|
||
const vy = (-panY / scale - minY) * ms;
|
||
const vw = (rect.width / scale) * ms;
|
||
const vh = (rect.height / scale) * ms;
|
||
mCtx.strokeStyle = '#6ea8fe';
|
||
mCtx.lineWidth = 1;
|
||
mCtx.strokeRect(vx, vy, vw, vh);
|
||
}
|
||
|
||
// ─── Init ───
|
||
renderTables();
|
||
drawRelationships();
|
||
applyTransform();
|
||
setTimeout(updateMinimap, 100);
|
||
window.addEventListener('resize', updateMinimap);
|
||
</script>
|
||
</body>
|
||
</html>
|