244 lines
9.1 KiB
PHP
244 lines
9.1 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>FlowGrid (Vue 2 POC)</title>
|
|
<style>
|
|
:root{
|
|
--ink:#0f172a;
|
|
--reqBorder:#2e7d32; --reqFill:#eef7ef;
|
|
--recBorder:#8a8a8a; --recFill:#ffffff;
|
|
--doneBorder:#9ca3af; --doneInk:#475569;
|
|
--modeCol:180px; --gap:12px;
|
|
--step:260px; --arrow:34px; --done:110px;
|
|
--bg:#f6f7fb;
|
|
}
|
|
html,body{margin:0;background:var(--bg);font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:var(--ink)}
|
|
.wrap{margin:24px auto 48px;padding:0 16px}
|
|
h1{font-size:22px;margin:0 0 8px}
|
|
.grid{display:flex;flex-direction:column;gap:18px}
|
|
.lane{display:grid;grid-template-columns:var(--modeCol) 1fr;gap:var(--gap);align-items:center;background:#ffffffcc;padding:12px;border-radius:12px}
|
|
.mode{font-weight:700;text-align:center;background:#fff;padding:16px 10px}
|
|
.flow{display:grid;align-items:center;gap:8px;padding:8px 0;}
|
|
.header {grid-column: span 4; }
|
|
.step{border-radius:10px;padding:10px 12px;border:2px solid var(--reqBorder);background:var(--reqFill);min-height:64px}
|
|
.step .title{font-weight:700}
|
|
.step .sub{font-size:12px;opacity:.8}
|
|
.step.rec{border-color:var(--recBorder);border-style:dashed;background:var(--recFill)}
|
|
.slot{}
|
|
.arrow{font-size:22px;line-height:1;text-align:center}
|
|
.arrow.blank{color:transparent}
|
|
.done{justify-self:start;border-radius:999px;border:2px dashed var(--doneBorder);padding:10px 14px;color:var(--doneInk);background:#fff;text-align:center}
|
|
@media (max-width:900px){
|
|
.lane{grid-template-columns:1fr}
|
|
.mode{order:-1}
|
|
.flow{grid-template-columns:1fr; background:none}
|
|
.arrow{display:none}
|
|
}
|
|
.tag{display:inline-block;padding:2px 8px;border-radius:999px;border:1.5px solid var(--reqBorder);background:var(--reqFill);font-size:12px}
|
|
.tag.rec{border-color:var(--recBorder);background:var(--recFill);border-style:dashed}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app" class="wrap">
|
|
<div class="grid">
|
|
<!-- Title lane -->
|
|
<div class="lane">
|
|
<div class="mode"> </div>
|
|
<div class="flow" :style="{gridTemplateColumns: columns}">
|
|
<div class="header"><h1>{{ doc.title }}</h1></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lanes -->
|
|
<div class="lane" v-for="(lane, li) in doc.lanes" :key="li">
|
|
<div class="mode" v-html="lane.name"></div>
|
|
<div class="flow" :style="{gridTemplateColumns: columns}">
|
|
<template v-for="(step, si) in lane.steps">
|
|
<div class="step" :class="{ rec: step.tag === 'rec' }">
|
|
<div class="title">{{ step.code }}</div>
|
|
<div class="sub" v-html="formatSub(step)"></div>
|
|
</div>
|
|
<div class="arrow">→</div>
|
|
</template>
|
|
|
|
<!-- padding cells so columns align across lanes -->
|
|
<template v-for="n in (maxSteps - lane.steps.length)">
|
|
<div class="slot"></div>
|
|
<div class="arrow blank">→</div>
|
|
</template>
|
|
|
|
<div class="done">Done</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- The DSL spec lives directly in this script tag -->
|
|
<script type="text/plain" id="flow-spec">
|
|
TITLE: Online Teaching Requirements and Recommendations
|
|
VAR: --step=280px; --modeCol=180px
|
|
|
|
LANE: In Person (with Canvas)
|
|
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=rec
|
|
|
|
LANE: Online
|
|
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=req
|
|
STEP: GOTT 2 | Introduction to Asynchronous Online Teaching and Learning | weeks=4; hours=40; tag=req
|
|
|
|
LANE: Hybrid
|
|
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=req
|
|
STEP: GOTT 2 | Introduction to Asynchronous Online Teaching and Learning | weeks=4; hours=40; tag=req
|
|
STEP: GOTT 5 | Essentials of Blended Learning | weeks=2; hours=20; tag=rec
|
|
|
|
LANE: Online Live
|
|
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=req
|
|
STEP: GOTT 2 | Introduction to Asynchronous Online Teaching and Learning | weeks=4; hours=40; tag=req
|
|
STEP: GOTT 6 | Introduction to Live Online Teaching and Learning | weeks=2; hours=20; tag=rec
|
|
|
|
LANE: HyFlex
|
|
STEP: GOTT 1 | Intro to Online Teaching with Canvas | weeks=2; hours=20; tag=req
|
|
STEP: GOTT 2 | Introduction to Asynchronous Online Teaching and Learning | weeks=4; hours=40; tag=req
|
|
STEP: GOTT 6 | Introduction to Live Online Teaching and Learning | weeks=2; hours=20; tag=rec
|
|
STEP: HyFlex Tech Training | ~1 hour on-site | desc=~1 hour on-site; tag=rec
|
|
</script>
|
|
|
|
<!-- Vue 2 -->
|
|
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
|
|
|
|
<script>
|
|
function normalizeTag(tag) {
|
|
if (!tag) return null;
|
|
const t = String(tag).toLowerCase().trim();
|
|
if (t === 'req' || t === 'required') return 'req';
|
|
if (t === 'rec' || t === 'recommended') return 'rec';
|
|
if (['none','na','n/a','optional'].includes(t)) return null;
|
|
return t;
|
|
}
|
|
|
|
function parseSpec(text) {
|
|
const lines = text.split(/\r?\n/).map(l => l.trim());
|
|
const doc = { title: 'Untitled Diagram', lanes: [], cssVars: {} };
|
|
let currentLane = null;
|
|
|
|
for (const raw of lines) {
|
|
if (!raw || raw.startsWith('#')) continue;
|
|
let m;
|
|
|
|
if ((m = raw.match(/^TITLE\s*:\s*(.+)$/i))) {
|
|
doc.title = m[1].trim();
|
|
continue;
|
|
}
|
|
if ((m = raw.match(/^VAR\s*:\s*(.+)$/i))) {
|
|
const parts = m[1].split(';').map(p => p.trim()).filter(Boolean);
|
|
for (const p of parts) {
|
|
const eq = p.indexOf('=');
|
|
if (eq > -1) {
|
|
const k = p.slice(0, eq).trim();
|
|
const v = p.slice(eq+1).trim();
|
|
doc.cssVars[k] = v;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if ((m = raw.match(/^LANE\s*:\s*(.+)$/i))) {
|
|
currentLane = { name: m[1].trim(), steps: [] };
|
|
doc.lanes.push(currentLane);
|
|
continue;
|
|
}
|
|
if ((m = raw.match(/^STEP\s*:\s*(.+)$/i))) {
|
|
if (!currentLane) throw new Error('STEP appears before any LANE');
|
|
const body = m[1];
|
|
const parts = body.split('|').map(s => s.trim());
|
|
if (parts.length < 2) throw new Error('STEP needs CODE | LABEL | ...');
|
|
|
|
const code = parts[0];
|
|
const label = parts[1];
|
|
const attrsBlob = parts[2] || '';
|
|
const kw = {};
|
|
|
|
if (attrsBlob) {
|
|
attrsBlob.split(';').map(s => s.trim()).filter(Boolean).forEach(kv => {
|
|
const eq = kv.indexOf('=');
|
|
if (eq > -1) {
|
|
kw[kv.slice(0,eq).trim().toLowerCase()] = kv.slice(eq+1).trim();
|
|
} else if (['req','rec'].includes(kv.toLowerCase())) {
|
|
kw['tag'] = kv.toLowerCase();
|
|
}
|
|
});
|
|
}
|
|
|
|
currentLane.steps.push({
|
|
code,
|
|
label,
|
|
weeks: kw['weeks'] || kw['w'] || null,
|
|
hours: kw['hours'] || kw['hrs'] || kw['h'] || null,
|
|
tag: normalizeTag(kw['tag']),
|
|
desc: kw['desc'] || null,
|
|
klass: kw['class'] || kw['klass'] || null
|
|
});
|
|
continue;
|
|
}
|
|
|
|
throw new Error('Unrecognized line: ' + raw);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
new Vue({
|
|
el: '#app',
|
|
data() {
|
|
const specText = document.getElementById('flow-spec').textContent;
|
|
const doc = parseSpec(specText);
|
|
|
|
// Apply CSS custom properties from VAR:
|
|
for (const k in doc.cssVars) {
|
|
if (k.trim().startsWith('--')) {
|
|
document.documentElement.style.setProperty(k.trim(), doc.cssVars[k]);
|
|
}
|
|
}
|
|
return { doc };
|
|
},
|
|
computed: {
|
|
maxSteps() {
|
|
if (!this.doc.lanes.length) return 1;
|
|
return Math.max.apply(null, this.doc.lanes.map(l => l.steps.length || 0).concat([1]));
|
|
},
|
|
columns() {
|
|
const pairs = Array(this.maxSteps).fill('var(--step) var(--arrow)').join(' ');
|
|
return pairs + ' var(--done)';
|
|
}
|
|
},
|
|
methods: {
|
|
formatSub(step) {
|
|
let core = '';
|
|
if (step.desc) {
|
|
core = this.escapeHTML(step.desc);
|
|
} else {
|
|
const bits = [this.escapeHTML(step.label)];
|
|
if (step.weeks && step.hours) {
|
|
bits.push(` · ${this.escapeHTML(step.weeks)} weeks (~${this.escapeHTML(step.hours)} hrs)`);
|
|
} else if (step.weeks) {
|
|
bits.push(` · ${this.escapeHTML(step.weeks)} weeks`);
|
|
} else if (step.hours) {
|
|
bits.push(` · ~${this.escapeHTML(step.hours)} hrs`);
|
|
}
|
|
core = bits.join('');
|
|
}
|
|
let tagHTML = '';
|
|
if (step.tag === 'req') tagHTML = '<span class="tag">Required</span>';
|
|
else if (step.tag === 'rec') tagHTML = '<span class="tag rec">Recommended</span>';
|
|
return tagHTML ? core + ' · ' + tagHTML : core;
|
|
},
|
|
escapeHTML(s) {
|
|
const d = document.createElement('div');
|
|
d.innerText = String(s);
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|