SequenceDiagram/scripts/sequence/Generator.js

840 lines
21 KiB
JavaScript

define(['core/ArrayUtilities'], (array) => {
'use strict';
class AgentState {
constructor({
visible = false,
locked = false,
blocked = false,
highlighted = false,
group = null,
covered = false,
} = {}) {
this.visible = visible;
this.locked = locked;
this.blocked = blocked;
this.highlighted = highlighted;
this.group = group;
this.covered = covered;
}
}
AgentState.LOCKED = new AgentState({locked: true});
AgentState.DEFAULT = new AgentState();
const Agent = {
equals: (a, b) => {
return a.name === b.name;
},
make: (name, {anchorRight = false} = {}) => {
return {name, anchorRight};
},
getName: (agent) => {
return agent.name;
},
hasFlag: (flag, has = true) => {
return (agent) => (agent.flags.includes(flag) === has);
},
};
const MERGABLE = {
'agent begin': {
check: ['mode'],
merge: ['agentNames'],
siblings: new Set(['agent highlight']),
},
'agent end': {
check: ['mode'],
merge: ['agentNames'],
siblings: new Set(['agent highlight']),
},
'agent highlight': {
check: ['highlighted'],
merge: ['agentNames'],
siblings: new Set(['agent begin', 'agent end']),
},
};
function mergableParallel(target, copy) {
const info = MERGABLE[target.type];
if(!info || target.type !== copy.type) {
return false;
}
if(info.check.some((c) => target[c] !== copy[c])) {
return false;
}
return true;
}
function performMerge(target, copy) {
const info = MERGABLE[target.type];
info.merge.forEach((m) => {
array.mergeSets(target[m], copy[m]);
});
}
function iterateRemoval(list, fn) {
for(let i = 0; i < list.length;) {
const remove = fn(list[i], i);
if(remove) {
list.splice(i, 1);
} else {
++ i;
}
}
}
function performParallelMergers(stages) {
iterateRemoval(stages, (stage, i) => {
for(let j = 0; j < i; ++ j) {
if(mergableParallel(stages[j], stage)) {
performMerge(stages[j], stage);
return true;
}
}
return false;
});
}
function findViableSequentialMergers(stages) {
const mergers = new Set();
const types = stages.map(({type}) => type);
types.forEach((type) => {
const info = MERGABLE[type];
if(!info) {
return;
}
if(types.every((sType) =>
(type === sType || info.siblings.has(sType))
)) {
mergers.add(type);
}
});
return mergers;
}
function performSequentialMergers(lastViable, viable, lastStages, stages) {
iterateRemoval(stages, (stage) => {
if(!lastViable.has(stage.type) || !viable.has(stage.type)) {
return false;
}
for(let j = 0; j < lastStages.length; ++ j) {
if(mergableParallel(lastStages[j], stage)) {
performMerge(lastStages[j], stage);
return true;
}
}
return false;
});
}
function optimiseStages(stages) {
let lastStages = [];
let lastViable = new Set();
for(let i = 0; i < stages.length;) {
const stage = stages[i];
let subStages = null;
if(stage.type === 'parallel') {
subStages = stage.stages;
} else {
subStages = [stage];
}
performParallelMergers(subStages);
const viable = findViableSequentialMergers(subStages);
performSequentialMergers(lastViable, viable, lastStages, subStages);
lastViable = viable;
lastStages = subStages;
if(subStages.length === 0) {
stages.splice(i, 1);
} else if(stage.type === 'parallel' && subStages.length === 1) {
stages.splice(i, 1, subStages[0]);
++ i;
} else {
++ i;
}
}
}
function swapBegin(stage, mode) {
if(stage.type === 'agent begin') {
stage.mode = mode;
return true;
}
if(stage.type === 'parallel') {
let any = false;
stage.stages.forEach((subStage) => {
if(subStage.type === 'agent begin') {
subStage.mode = mode;
any = true;
}
});
return any;
}
return false;
}
function swapFirstBegin(stages, mode) {
for(let i = 0; i < stages.length; ++ i) {
if(swapBegin(stages[i], mode)) {
break;
}
}
}
function addBounds(target, agentL, agentR, involvedAgents = null) {
array.remove(target, agentL, Agent.equals);
array.remove(target, agentR, Agent.equals);
let indexL = 0;
let indexR = target.length;
if(involvedAgents) {
const found = (involvedAgents
.map((agent) => array.indexOf(target, agent, Agent.equals))
.filter((p) => (p !== -1))
);
indexL = found.reduce((a, b) => Math.min(a, b), target.length);
indexR = found.reduce((a, b) => Math.max(a, b), indexL) + 1;
}
target.splice(indexL, 0, agentL);
target.splice(indexR + 1, 0, agentR);
return {indexL, indexR: indexR + 1};
}
const NOTE_DEFAULT_AGENTS = {
'note over': [{name: '[', flags: []}, {name: ']', flags: []}],
'note left': [{name: '[', flags: []}],
'note right': [{name: ']', flags: []}],
};
return class Generator {
constructor() {
this.agentStates = new Map();
this.agentAliases = new Map();
this.activeGroups = new Map();
this.agents = [];
this.labelPattern = null;
this.blockCount = 0;
this.nesting = [];
this.markers = new Set();
this.currentSection = null;
this.currentNest = null;
this.stageHandlers = {
'block begin': this.handleBlockBegin.bind(this),
'block split': this.handleBlockSplit.bind(this),
'block end': this.handleBlockEnd.bind(this),
'group begin': this.handleGroupBegin.bind(this),
'mark': this.handleMark.bind(this),
'async': this.handleAsync.bind(this),
'agent define': this.handleAgentDefine.bind(this),
'agent begin': this.handleAgentBegin.bind(this),
'agent end': this.handleAgentEnd.bind(this),
'label pattern': this.handleLabelPattern.bind(this),
'connect': this.handleConnect.bind(this),
'note over': this.handleNote.bind(this),
'note left': this.handleNote.bind(this),
'note right': this.handleNote.bind(this),
'note between': this.handleNote.bind(this),
};
this.expandGroupedAgent = this.expandGroupedAgent.bind(this);
this.handleStage = this.handleStage.bind(this);
this.convertAgent = this.convertAgent.bind(this);
this.endGroup = this.endGroup.bind(this);
}
convertAgent({alias, name}) {
if(alias) {
if(this.agentAliases.has(name)) {
throw new Error(
'Cannot alias ' + name + '; it is already an alias'
);
}
const old = this.agentAliases.get(alias);
if(
(old && old !== alias) ||
this.agents.some((agent) => (agent.name === alias))
) {
throw new Error(
'Cannot use ' + alias +
' as an alias; it is already in use'
);
}
this.agentAliases.set(alias, name);
}
return Agent.make(this.agentAliases.get(name) || name);
}
addStage(stage, isVisible = true) {
if(!stage) {
return;
}
if(stage.ln === undefined) {
stage.ln = this.latestLine;
}
this.currentSection.stages.push(stage);
if(isVisible) {
this.currentNest.hasContent = true;
}
}
addParallelStages(stages) {
const viableStages = stages.filter((stage) => Boolean(stage));
if(viableStages.length === 0) {
return;
}
if(viableStages.length === 1) {
return this.addStage(viableStages[0]);
}
viableStages.forEach((stage) => {
if(stage.ln === undefined) {
stage.ln = this.latestLine;
}
});
return this.addStage({
type: 'parallel',
stages: viableStages,
});
}
defineAgents(colAgents) {
array.mergeSets(this.currentNest.agents, colAgents, Agent.equals);
array.mergeSets(this.agents, colAgents, Agent.equals);
}
getAgentState(agent) {
return this.agentStates.get(agent.name) || AgentState.DEFAULT;
}
updateAgentState(agent, change) {
const state = this.agentStates.get(agent.name);
if(state) {
Object.assign(state, change);
} else {
this.agentStates.set(agent.name, new AgentState(change));
}
}
validateAgents(agents, {
allowGrouped = false,
rejectGrouped = false,
} = {}) {
agents.forEach((agent) => {
const state = this.getAgentState(agent);
if(state.covered) {
throw new Error(
'Agent ' + agent.name + ' is hidden behind group'
);
}
if(rejectGrouped && state.group !== null) {
throw new Error('Agent ' + agent.name + ' is in a group');
}
if(state.blocked && (!allowGrouped || state.group === null)) {
throw new Error('Duplicate agent name: ' + agent.name);
}
if(agent.name.startsWith('__')) {
throw new Error(agent.name + ' is a reserved name');
}
});
}
setAgentVis(colAgents, visible, mode, checked = false) {
const seen = new Set();
const filteredAgents = colAgents.filter((agent) => {
if(seen.has(agent.name)) {
return false;
}
seen.add(agent.name);
const state = this.getAgentState(agent);
if(state.locked || state.blocked) {
if(checked) {
throw new Error(
'Cannot begin/end agent: ' + agent.name
);
} else {
return false;
}
}
return state.visible !== visible;
});
if(filteredAgents.length === 0) {
return null;
}
filteredAgents.forEach((agent) => {
this.updateAgentState(agent, {visible});
});
this.defineAgents(filteredAgents);
return {
type: (visible ? 'agent begin' : 'agent end'),
agentNames: filteredAgents.map(Agent.getName),
mode,
};
}
setAgentHighlight(colAgents, highlighted, checked = false) {
const filteredAgents = colAgents.filter((agent) => {
const state = this.getAgentState(agent);
if(state.locked || state.blocked) {
if(checked) {
throw new Error(
'Cannot highlight agent: ' + agent.name
);
} else {
return false;
}
}
return state.visible && (state.highlighted !== highlighted);
});
if(filteredAgents.length === 0) {
return null;
}
filteredAgents.forEach((agent) => {
this.updateAgentState(agent, {highlighted});
});
return {
type: 'agent highlight',
agentNames: filteredAgents.map(Agent.getName),
highlighted,
};
}
beginNested(mode, label, name, ln) {
const leftAgent = Agent.make(name + '[', {anchorRight: true});
const rightAgent = Agent.make(name + ']');
const agents = [leftAgent, rightAgent];
const stages = [];
this.currentSection = {
header: {
type: 'block begin',
mode,
label,
left: leftAgent.name,
right: rightAgent.name,
ln,
},
stages,
};
this.currentNest = {
mode,
agents,
leftAgent,
rightAgent,
hasContent: false,
sections: [this.currentSection],
};
this.agentStates.set(leftAgent.name, AgentState.LOCKED);
this.agentStates.set(rightAgent.name, AgentState.LOCKED);
this.nesting.push(this.currentNest);
return {agents, stages};
}
nextBlockName() {
const name = '__BLOCK' + this.blockCount;
++ this.blockCount;
return name;
}
handleBlockBegin({ln, mode, label}) {
this.beginNested(mode, label, this.nextBlockName(), ln);
}
handleBlockSplit({ln, mode, label}) {
if(this.currentNest.mode !== 'if') {
throw new Error(
'Invalid block nesting ("else" inside ' +
this.currentNest.mode + ')'
);
}
optimiseStages(this.currentSection.stages);
this.currentSection = {
header: {
type: 'block split',
mode,
label,
left: this.currentNest.leftAgent.name,
right: this.currentNest.rightAgent.name,
ln,
},
stages: [],
};
this.currentNest.sections.push(this.currentSection);
}
handleBlockEnd() {
if(this.nesting.length <= 1) {
throw new Error('Invalid block nesting (too many "end"s)');
}
optimiseStages(this.currentSection.stages);
const nested = this.nesting.pop();
this.currentNest = array.last(this.nesting);
this.currentSection = array.last(this.currentNest.sections);
if(nested.hasContent) {
this.defineAgents(nested.agents);
addBounds(
this.agents,
nested.leftAgent,
nested.rightAgent,
nested.agents
);
nested.sections.forEach((section) => {
this.currentSection.stages.push(section.header);
this.currentSection.stages.push(...section.stages);
});
this.addStage({
type: 'block end',
left: nested.leftAgent.name,
right: nested.rightAgent.name,
});
} else {
throw new Error('Empty block');
}
}
makeGroupDetails(agents, alias) {
const colAgents = agents.map(this.convertAgent);
this.validateAgents(colAgents, {rejectGrouped: true});
if(this.agentStates.has(alias)) {
throw new Error('Duplicate agent name: ' + alias);
}
const name = this.nextBlockName();
const leftAgent = Agent.make(name + '[', {anchorRight: true});
const rightAgent = Agent.make(name + ']');
this.agentStates.set(leftAgent.name, AgentState.LOCKED);
this.agentStates.set(rightAgent.name, AgentState.LOCKED);
this.updateAgentState(
{name: alias},
{blocked: true, group: alias}
);
this.defineAgents(colAgents);
const {indexL, indexR} = addBounds(
this.agents,
leftAgent,
rightAgent,
colAgents
);
const agentsCovered = [];
const agentsContained = colAgents.slice();
for(let i = indexL + 1; i < indexR; ++ i) {
agentsCovered.push(this.agents[i]);
}
array.removeAll(agentsCovered, agentsContained, Agent.equals);
return {
colAgents,
leftAgent,
rightAgent,
agentsContained,
agentsCovered,
};
}
handleGroupBegin({agents, mode, label, alias}) {
const details = this.makeGroupDetails(agents, alias);
details.agentsContained.forEach((agent) => {
this.updateAgentState(agent, {group: alias});
});
details.agentsCovered.forEach((agent) => {
this.updateAgentState(agent, {covered: true});
});
this.activeGroups.set(alias, details);
this.addStage(this.setAgentVis(details.colAgents, true, 'box'));
this.addStage({
type: 'block begin',
mode,
label,
left: details.leftAgent.name,
right: details.rightAgent.name,
});
}
endGroup({name}) {
const details = this.activeGroups.get(name);
if(!details) {
return null;
}
this.activeGroups.delete(name);
details.agentsContained.forEach((agent) => {
this.updateAgentState(agent, {group: null});
});
details.agentsCovered.forEach((agent) => {
this.updateAgentState(agent, {covered: false});
});
this.updateAgentState({name}, {group: null});
return {
type: 'block end',
left: details.leftAgent.name,
right: details.rightAgent.name,
};
}
handleMark({name}) {
this.markers.add(name);
this.addStage({type: 'mark', name}, false);
}
handleAsync({target}) {
if(target !== '' && !this.markers.has(target)) {
throw new Error('Unknown marker: ' + target);
}
this.addStage({type: 'async', target}, false);
}
handleLabelPattern({pattern}) {
this.labelPattern = pattern.slice();
for(let i = 0; i < this.labelPattern.length; ++ i) {
const part = this.labelPattern[i];
if(typeof part === 'object' && part.start !== undefined) {
this.labelPattern[i] = Object.assign({
current: part.start,
}, part);
}
}
}
applyLabelPattern(label) {
let result = '';
const tokens = {
'label': label,
};
this.labelPattern.forEach((part) => {
if(typeof part === 'string') {
result += part;
} else if(part.token !== undefined) {
result += tokens[part.token];
} else if(part.current !== undefined) {
result += part.current.toFixed(part.dp);
part.current += part.inc;
}
});
return result;
}
expandGroupedAgent(agent) {
const group = this.getAgentState(agent).group;
if(!group) {
return [agent];
}
const details = this.activeGroups.get(group);
return [details.leftAgent, details.rightAgent];
}
expandGroupedAgentConnection(agents) {
const agents1 = this.expandGroupedAgent(agents[0]);
const agents2 = this.expandGroupedAgent(agents[1]);
let ind1 = array.indexOf(this.agents, agents1[0], Agent.equals);
let ind2 = array.indexOf(this.agents, agents2[0], Agent.equals);
if(ind1 === -1) {
ind1 = this.agents.length;
}
if(ind2 === -1) {
ind2 = this.agents.length;
}
if(ind1 === ind2) {
// Self-connection
return [array.last(agents1), array.last(agents2)];
} else if(ind1 < ind2) {
return [array.last(agents1), agents2[0]];
} else {
return [agents1[0], array.last(agents2)];
}
}
filterConnectFlags(agents) {
const beginAgents = (agents
.filter(Agent.hasFlag('begin'))
.map(this.convertAgent)
);
const endAgents = (agents
.filter(Agent.hasFlag('end'))
.map(this.convertAgent)
);
if(array.hasIntersection(beginAgents, endAgents, Agent.equals)) {
throw new Error('Cannot set agent visibility multiple times');
}
const startAgents = (agents
.filter(Agent.hasFlag('start'))
.map(this.convertAgent)
);
const stopAgents = (agents
.filter(Agent.hasFlag('stop'))
.map(this.convertAgent)
);
array.mergeSets(stopAgents, endAgents);
if(array.hasIntersection(startAgents, stopAgents, Agent.equals)) {
throw new Error('Cannot set agent highlighting multiple times');
}
this.validateAgents(beginAgents);
this.validateAgents(endAgents);
this.validateAgents(startAgents);
this.validateAgents(stopAgents);
return {beginAgents, endAgents, startAgents, stopAgents};
}
handleConnect({agents, label, options}) {
const flags = this.filterConnectFlags(agents);
let colAgents = agents.map(this.convertAgent);
this.validateAgents(colAgents, {allowGrouped: true});
const allAgents = array.flatMap(colAgents, this.expandGroupedAgent);
this.defineAgents(allAgents);
colAgents = this.expandGroupedAgentConnection(colAgents);
const agentNames = colAgents.map(Agent.getName);
const implicitBegin = (agents
.filter(Agent.hasFlag('begin', false))
.map(this.convertAgent)
);
this.addStage(this.setAgentVis(implicitBegin, true, 'box'));
const connectStage = {
type: 'connect',
agentNames,
label: this.applyLabelPattern(label),
options,
};
this.addParallelStages([
this.setAgentVis(flags.beginAgents, true, 'box', true),
this.setAgentHighlight(flags.startAgents, true, true),
connectStage,
this.setAgentHighlight(flags.stopAgents, false, true),
this.setAgentVis(flags.endAgents, false, 'cross', true),
]);
}
handleNote({type, agents, mode, label}) {
let colAgents = null;
if(agents.length === 0) {
colAgents = NOTE_DEFAULT_AGENTS[type] || [];
} else {
colAgents = agents.map(this.convertAgent);
}
this.validateAgents(colAgents, {allowGrouped: true});
colAgents = array.flatMap(colAgents, this.expandGroupedAgent);
const agentNames = colAgents.map(Agent.getName);
const uniqueAgents = new Set(agentNames).size;
if(type === 'note between' && uniqueAgents < 2) {
throw new Error('note between requires at least 2 agents');
}
this.addStage(this.setAgentVis(colAgents, true, 'box'));
this.defineAgents(colAgents);
this.addStage({
type,
agentNames,
mode,
label,
});
}
handleAgentDefine({agents}) {
const colAgents = agents.map(this.convertAgent);
this.validateAgents(colAgents);
this.defineAgents(colAgents);
}
handleAgentBegin({agents, mode}) {
const colAgents = agents.map(this.convertAgent);
this.validateAgents(colAgents);
this.addStage(this.setAgentVis(colAgents, true, mode, true));
}
handleAgentEnd({agents, mode}) {
const groupAgents = (agents
.filter((agent) => this.activeGroups.has(agent.name))
);
const colAgents = (agents
.filter((agent) => !this.activeGroups.has(agent.name))
.map(this.convertAgent)
);
this.validateAgents(colAgents);
this.addParallelStages([
this.setAgentHighlight(colAgents, false),
this.setAgentVis(colAgents, false, mode, true),
...groupAgents.map(this.endGroup),
]);
}
handleStage(stage) {
this.latestLine = stage.ln;
try {
const handler = this.stageHandlers[stage.type];
if(!handler) {
throw new Error('Unknown command: ' + stage.type);
}
handler(stage);
} catch(e) {
if(typeof e === 'object' && e.message) {
throw new Error(e.message + ' at line ' + (stage.ln + 1));
}
}
}
generate({stages, meta = {}}) {
this.agentStates.clear();
this.markers.clear();
this.agentAliases.clear();
this.activeGroups.clear();
this.agents.length = 0;
this.blockCount = 0;
this.nesting.length = 0;
this.labelPattern = [{token: 'label'}];
const globals = this.beginNested('global', '', '', 0);
stages.forEach(this.handleStage);
if(this.nesting.length !== 1) {
throw new Error(
'Unterminated section at line ' +
(this.currentSection.header.ln + 1)
);
}
if(this.activeGroups.size > 0) {
throw new Error('Unterminated group');
}
const terminators = meta.terminators || 'none';
this.addParallelStages([
this.setAgentHighlight(this.agents, false),
this.setAgentVis(this.agents, false, terminators),
]);
addBounds(
this.agents,
this.currentNest.leftAgent,
this.currentNest.rightAgent
);
optimiseStages(globals.stages);
swapFirstBegin(globals.stages, meta.headers || 'box');
return {
meta: {
title: meta.title,
theme: meta.theme,
},
agents: this.agents.slice(),
stages: globals.stages,
};
}
};
});