Refactor to enable formatted text everywhere, and make 'agent' dichotomy in generator clearer through naming [#29]

This commit is contained in:
David Evans 2018-01-14 23:10:48 +00:00
parent 2b5f7eea2b
commit 0f22dc7f94
29 changed files with 2010 additions and 1636 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -21,35 +21,52 @@ define(['core/ArrayUtilities'], (array) => {
AgentState.LOCKED = new AgentState({locked: true});
AgentState.DEFAULT = new AgentState();
const Agent = {
// Agent from Parser: {name, flags}
const PAgent = {
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);
return (pAgent) => (pAgent.flags.includes(flag) === has);
},
};
// Agent from Generator: {id, formattedLabel, anchorRight}
const GAgent = {
equals: (a, b) => {
return a.id === b.id;
},
make: (id, {anchorRight = false} = {}) => {
return {id, anchorRight};
},
indexOf: (list, gAgent) => {
return array.indexOf(list, gAgent, GAgent.equals);
},
hasIntersection: (a, b) => {
return array.hasIntersection(a, b, GAgent.equals);
},
};
const NOTE_DEFAULT_G_AGENTS = {
'note over': [GAgent.make('['), GAgent.make(']')],
'note left': [GAgent.make('[')],
'note right': [GAgent.make(']')],
};
const MERGABLE = {
'agent begin': {
check: ['mode'],
merge: ['agentNames'],
merge: ['agentIDs'],
siblings: new Set(['agent highlight']),
},
'agent end': {
check: ['mode'],
merge: ['agentNames'],
merge: ['agentIDs'],
siblings: new Set(['agent highlight']),
},
'agent highlight': {
check: ['highlighted'],
merge: ['agentNames'],
merge: ['agentIDs'],
siblings: new Set(['agent begin', 'agent end']),
},
};
@ -183,39 +200,33 @@ define(['core/ArrayUtilities'], (array) => {
}
}
function addBounds(target, agentL, agentR, involvedAgents = null) {
array.remove(target, agentL, Agent.equals);
array.remove(target, agentR, Agent.equals);
function addBounds(allGAgents, gAgentL, gAgentR, involvedGAgents = null) {
array.remove(allGAgents, gAgentL, GAgent.equals);
array.remove(allGAgents, gAgentR, GAgent.equals);
let indexL = 0;
let indexR = target.length;
if(involvedAgents) {
const found = (involvedAgents
.map((agent) => array.indexOf(target, agent, Agent.equals))
let indexR = allGAgents.length;
if(involvedGAgents) {
const found = (involvedGAgents
.map((gAgent) => GAgent.indexOf(allGAgents, gAgent))
.filter((p) => (p !== -1))
);
indexL = found.reduce((a, b) => Math.min(a, b), target.length);
indexL = found.reduce((a, b) => Math.min(a, b), allGAgents.length);
indexR = found.reduce((a, b) => Math.max(a, b), indexL) + 1;
}
target.splice(indexL, 0, agentL);
target.splice(indexR + 1, 0, agentR);
allGAgents.splice(indexL, 0, gAgentL);
allGAgents.splice(indexR + 1, 0, gAgentR);
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.gAgents = [];
this.labelPattern = null;
this.blockCount = 0;
this.nesting = [];
@ -240,13 +251,13 @@ define(['core/ArrayUtilities'], (array) => {
'note right': this.handleNote.bind(this),
'note between': this.handleNote.bind(this),
};
this.expandGroupedAgent = this.expandGroupedAgent.bind(this);
this.expandGroupedGAgent = this.expandGroupedGAgent.bind(this);
this.handleStage = this.handleStage.bind(this);
this.convertAgent = this.convertAgent.bind(this);
this.toGAgent = this.toGAgent.bind(this);
this.endGroup = this.endGroup.bind(this);
}
convertAgent({alias, name}) {
toGAgent({alias, name}) {
if(alias) {
if(this.agentAliases.has(name)) {
throw new Error(
@ -256,7 +267,7 @@ define(['core/ArrayUtilities'], (array) => {
const old = this.agentAliases.get(alias);
if(
(old && old !== alias) ||
this.agents.some((agent) => (agent.name === alias))
this.gAgents.some((gAgent) => (gAgent.id === alias))
) {
throw new Error(
'Cannot use ' + alias +
@ -265,7 +276,7 @@ define(['core/ArrayUtilities'], (array) => {
}
this.agentAliases.set(alias, name);
}
return Agent.make(this.agentAliases.get(name) || name);
return GAgent.make(this.agentAliases.get(name) || name);
}
addStage(stage, isVisible = true) {
@ -300,138 +311,139 @@ define(['core/ArrayUtilities'], (array) => {
});
}
defineAgents(colAgents) {
array.mergeSets(this.currentNest.agents, colAgents, Agent.equals);
array.mergeSets(this.agents, colAgents, Agent.equals);
defineGAgents(gAgents) {
array.mergeSets(this.currentNest.gAgents, gAgents, GAgent.equals);
array.mergeSets(this.gAgents, gAgents, GAgent.equals);
}
getAgentState(agent) {
return this.agentStates.get(agent.name) || AgentState.DEFAULT;
getGAgentState(gAgent) {
return this.agentStates.get(gAgent.id) || AgentState.DEFAULT;
}
updateAgentState(agent, change) {
const state = this.agentStates.get(agent.name);
updateGAgentState(gAgent, change) {
const state = this.agentStates.get(gAgent.id);
if(state) {
Object.assign(state, change);
} else {
this.agentStates.set(agent.name, new AgentState(change));
this.agentStates.set(gAgent.id, new AgentState(change));
}
}
validateAgents(agents, {
replaceGAgentState(gAgent, state) {
this.agentStates.set(gAgent.id, state);
}
validateGAgents(gAgents, {
allowGrouped = false,
rejectGrouped = false,
} = {}) {
agents.forEach((agent) => {
const state = this.getAgentState(agent);
gAgents.forEach((gAgent) => {
const state = this.getGAgentState(gAgent);
if(state.covered) {
throw new Error(
'Agent ' + agent.name + ' is hidden behind group'
'Agent ' + gAgent.id + ' is hidden behind group'
);
}
if(rejectGrouped && state.group !== null) {
throw new Error('Agent ' + agent.name + ' is in a group');
throw new Error('Agent ' + gAgent.id + ' is in a group');
}
if(state.blocked && (!allowGrouped || state.group === null)) {
throw new Error('Duplicate agent name: ' + agent.name);
throw new Error('Duplicate agent name: ' + gAgent.id);
}
if(agent.name.startsWith('__')) {
throw new Error(agent.name + ' is a reserved name');
if(gAgent.id.startsWith('__')) {
throw new Error(gAgent.id + ' is a reserved name');
}
});
}
setAgentVis(colAgents, visible, mode, checked = false) {
setGAgentVis(gAgents, visible, mode, checked = false) {
const seen = new Set();
const filteredAgents = colAgents.filter((agent) => {
if(seen.has(agent.name)) {
const filteredGAgents = gAgents.filter((gAgent) => {
if(seen.has(gAgent.id)) {
return false;
}
seen.add(agent.name);
const state = this.getAgentState(agent);
seen.add(gAgent.id);
const state = this.getGAgentState(gAgent);
if(state.locked || state.blocked) {
if(checked) {
throw new Error(
'Cannot begin/end agent: ' + agent.name
);
throw new Error('Cannot begin/end agent: ' + gAgent.id);
} else {
return false;
}
}
return state.visible !== visible;
});
if(filteredAgents.length === 0) {
if(filteredGAgents.length === 0) {
return null;
}
filteredAgents.forEach((agent) => {
this.updateAgentState(agent, {visible});
filteredGAgents.forEach((gAgent) => {
this.updateGAgentState(gAgent, {visible});
});
this.defineAgents(filteredAgents);
this.defineGAgents(filteredGAgents);
return {
type: (visible ? 'agent begin' : 'agent end'),
agentNames: filteredAgents.map(Agent.getName),
agentIDs: filteredGAgents.map((gAgent) => gAgent.id),
mode,
};
}
setAgentHighlight(colAgents, highlighted, checked = false) {
const filteredAgents = colAgents.filter((agent) => {
const state = this.getAgentState(agent);
setGAgentHighlight(gAgents, highlighted, checked = false) {
const filteredGAgents = gAgents.filter((gAgent) => {
const state = this.getGAgentState(gAgent);
if(state.locked || state.blocked) {
if(checked) {
throw new Error(
'Cannot highlight agent: ' + agent.name
);
throw new Error('Cannot highlight agent: ' + gAgent.id);
} else {
return false;
}
}
return state.visible && (state.highlighted !== highlighted);
});
if(filteredAgents.length === 0) {
if(filteredGAgents.length === 0) {
return null;
}
filteredAgents.forEach((agent) => {
this.updateAgentState(agent, {highlighted});
filteredGAgents.forEach((gAgent) => {
this.updateGAgentState(gAgent, {highlighted});
});
return {
type: 'agent highlight',
agentNames: filteredAgents.map(Agent.getName),
agentIDs: filteredGAgents.map((gAgent) => gAgent.id),
highlighted,
};
}
beginNested(mode, label, name, ln) {
const leftAgent = Agent.make(name + '[', {anchorRight: true});
const rightAgent = Agent.make(name + ']');
const agents = [leftAgent, rightAgent];
beginNested(blockType, {tag, label, name, ln}) {
const leftGAgent = GAgent.make(name + '[', {anchorRight: true});
const rightGAgent = GAgent.make(name + ']');
const gAgents = [leftGAgent, rightGAgent];
const stages = [];
this.currentSection = {
header: {
type: 'block begin',
mode,
label,
left: leftAgent.name,
right: rightAgent.name,
blockType,
tag: this.textFormatter(tag),
label: this.textFormatter(label),
left: leftGAgent.id,
right: rightGAgent.id,
ln,
},
stages,
};
this.currentNest = {
mode,
agents,
leftAgent,
rightAgent,
blockType,
gAgents,
leftGAgent,
rightGAgent,
hasContent: false,
sections: [this.currentSection],
};
this.agentStates.set(leftAgent.name, AgentState.LOCKED);
this.agentStates.set(rightAgent.name, AgentState.LOCKED);
this.replaceGAgentState(leftGAgent, AgentState.LOCKED);
this.replaceGAgentState(rightGAgent, AgentState.LOCKED);
this.nesting.push(this.currentNest);
return {agents, stages};
return {gAgents, stages};
}
nextBlockName() {
@ -440,25 +452,31 @@ define(['core/ArrayUtilities'], (array) => {
return name;
}
handleBlockBegin({ln, mode, label}) {
this.beginNested(mode, label, this.nextBlockName(), ln);
handleBlockBegin({ln, blockType, tag, label}) {
this.beginNested(blockType, {
tag,
label,
name: this.nextBlockName(),
ln,
});
}
handleBlockSplit({ln, mode, label}) {
if(this.currentNest.mode !== 'if') {
handleBlockSplit({ln, blockType, tag, label}) {
if(this.currentNest.blockType !== 'if') {
throw new Error(
'Invalid block nesting ("else" inside ' +
this.currentNest.mode + ')'
this.currentNest.blockType + ')'
);
}
optimiseStages(this.currentSection.stages);
this.currentSection = {
header: {
type: 'block split',
mode,
label,
left: this.currentNest.leftAgent.name,
right: this.currentNest.rightAgent.name,
blockType,
tag: this.textFormatter(tag),
label: this.textFormatter(label),
left: this.currentNest.leftGAgent.id,
right: this.currentNest.rightGAgent.id,
ln,
},
stages: [],
@ -476,12 +494,12 @@ define(['core/ArrayUtilities'], (array) => {
this.currentSection = array.last(this.currentNest.sections);
if(nested.hasContent) {
this.defineAgents(nested.agents);
this.defineGAgents(nested.gAgents);
addBounds(
this.agents,
nested.leftAgent,
nested.rightAgent,
nested.agents
this.gAgents,
nested.leftGAgent,
nested.rightGAgent,
nested.gAgents
);
nested.sections.forEach((section) => {
this.currentSection.stages.push(section.header);
@ -489,70 +507,71 @@ define(['core/ArrayUtilities'], (array) => {
});
this.addStage({
type: 'block end',
left: nested.leftAgent.name,
right: nested.rightAgent.name,
left: nested.leftGAgent.id,
right: nested.rightGAgent.id,
});
} else {
throw new Error('Empty block');
}
}
makeGroupDetails(agents, alias) {
const colAgents = agents.map(this.convertAgent);
this.validateAgents(colAgents, {rejectGrouped: true});
makeGroupDetails(pAgents, alias) {
const gAgents = pAgents.map(this.toGAgent);
this.validateGAgents(gAgents, {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},
const leftGAgent = GAgent.make(name + '[', {anchorRight: true});
const rightGAgent = GAgent.make(name + ']');
this.replaceGAgentState(leftGAgent, AgentState.LOCKED);
this.replaceGAgentState(rightGAgent, AgentState.LOCKED);
this.updateGAgentState(
GAgent.make(alias),
{blocked: true, group: alias}
);
this.defineAgents(colAgents);
this.defineGAgents(gAgents);
const {indexL, indexR} = addBounds(
this.agents,
leftAgent,
rightAgent,
colAgents
this.gAgents,
leftGAgent,
rightGAgent,
gAgents
);
const agentsCovered = [];
const agentsContained = colAgents.slice();
const gAgentsCovered = [];
const gAgentsContained = gAgents.slice();
for(let i = indexL + 1; i < indexR; ++ i) {
agentsCovered.push(this.agents[i]);
gAgentsCovered.push(this.gAgents[i]);
}
array.removeAll(agentsCovered, agentsContained, Agent.equals);
array.removeAll(gAgentsCovered, gAgentsContained, GAgent.equals);
return {
colAgents,
leftAgent,
rightAgent,
agentsContained,
agentsCovered,
gAgents,
leftGAgent,
rightGAgent,
gAgentsContained,
gAgentsCovered,
};
}
handleGroupBegin({agents, mode, label, alias}) {
handleGroupBegin({agents, blockType, tag, label, alias}) {
const details = this.makeGroupDetails(agents, alias);
details.agentsContained.forEach((agent) => {
this.updateAgentState(agent, {group: alias});
details.gAgentsContained.forEach((gAgent) => {
this.updateGAgentState(gAgent, {group: alias});
});
details.agentsCovered.forEach((agent) => {
this.updateAgentState(agent, {covered: true});
details.gAgentsCovered.forEach((gAgent) => {
this.updateGAgentState(gAgent, {covered: true});
});
this.activeGroups.set(alias, details);
this.addStage(this.setAgentVis(details.colAgents, true, 'box'));
this.addStage(this.setGAgentVis(details.gAgents, true, 'box'));
this.addStage({
type: 'block begin',
mode,
label,
left: details.leftAgent.name,
right: details.rightAgent.name,
blockType,
tag: this.textFormatter(tag),
label: this.textFormatter(label),
left: details.leftGAgent.id,
right: details.rightGAgent.id,
});
}
@ -563,18 +582,18 @@ define(['core/ArrayUtilities'], (array) => {
}
this.activeGroups.delete(name);
details.agentsContained.forEach((agent) => {
this.updateAgentState(agent, {group: null});
details.gAgentsContained.forEach((gAgent) => {
this.updateGAgentState(gAgent, {group: null});
});
details.agentsCovered.forEach((agent) => {
this.updateAgentState(agent, {covered: false});
details.gAgentsCovered.forEach((gAgent) => {
this.updateGAgentState(gAgent, {covered: false});
});
this.updateAgentState({name}, {group: null});
this.updateGAgentState(GAgent.make(name), {group: null});
return {
type: 'block end',
left: details.leftAgent.name,
right: details.rightAgent.name,
left: details.leftGAgent.id,
right: details.rightGAgent.id,
};
}
@ -620,156 +639,156 @@ define(['core/ArrayUtilities'], (array) => {
return result;
}
expandGroupedAgent(agent) {
const group = this.getAgentState(agent).group;
expandGroupedGAgent(gAgent) {
const group = this.getGAgentState(gAgent).group;
if(!group) {
return [agent];
return [gAgent];
}
const details = this.activeGroups.get(group);
return [details.leftAgent, details.rightAgent];
return [details.leftGAgent, details.rightGAgent];
}
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);
expandGroupedGAgentConnection(gAgents) {
const gAgents1 = this.expandGroupedGAgent(gAgents[0]);
const gAgents2 = this.expandGroupedGAgent(gAgents[1]);
let ind1 = GAgent.indexOf(this.gAgents, gAgents1[0]);
let ind2 = GAgent.indexOf(this.gAgents, gAgents2[0]);
if(ind1 === -1) {
ind1 = this.agents.length;
ind1 = this.gAgents.length;
}
if(ind2 === -1) {
ind2 = this.agents.length;
ind2 = this.gAgents.length;
}
if(ind1 === ind2) {
// Self-connection
return [array.last(agents1), array.last(agents2)];
return [array.last(gAgents1), array.last(gAgents2)];
} else if(ind1 < ind2) {
return [array.last(agents1), agents2[0]];
return [array.last(gAgents1), gAgents2[0]];
} else {
return [agents1[0], array.last(agents2)];
return [gAgents1[0], array.last(gAgents2)];
}
}
filterConnectFlags(agents) {
const beginAgents = (agents
.filter(Agent.hasFlag('begin'))
.map(this.convertAgent)
filterConnectFlags(pAgents) {
const beginGAgents = (pAgents
.filter(PAgent.hasFlag('begin'))
.map(this.toGAgent)
);
const endAgents = (agents
.filter(Agent.hasFlag('end'))
.map(this.convertAgent)
const endGAgents = (pAgents
.filter(PAgent.hasFlag('end'))
.map(this.toGAgent)
);
if(array.hasIntersection(beginAgents, endAgents, Agent.equals)) {
if(GAgent.hasIntersection(beginGAgents, endGAgents)) {
throw new Error('Cannot set agent visibility multiple times');
}
const startAgents = (agents
.filter(Agent.hasFlag('start'))
.map(this.convertAgent)
const startGAgents = (pAgents
.filter(PAgent.hasFlag('start'))
.map(this.toGAgent)
);
const stopAgents = (agents
.filter(Agent.hasFlag('stop'))
.map(this.convertAgent)
const stopGAgents = (pAgents
.filter(PAgent.hasFlag('stop'))
.map(this.toGAgent)
);
array.mergeSets(stopAgents, endAgents);
if(array.hasIntersection(startAgents, stopAgents, Agent.equals)) {
array.mergeSets(stopGAgents, endGAgents);
if(GAgent.hasIntersection(startGAgents, stopGAgents)) {
throw new Error('Cannot set agent highlighting multiple times');
}
this.validateAgents(beginAgents);
this.validateAgents(endAgents);
this.validateAgents(startAgents);
this.validateAgents(stopAgents);
this.validateGAgents(beginGAgents);
this.validateGAgents(endGAgents);
this.validateGAgents(startGAgents);
this.validateGAgents(stopGAgents);
return {beginAgents, endAgents, startAgents, stopAgents};
return {beginGAgents, endGAgents, startGAgents, stopGAgents};
}
handleConnect({agents, label, options}) {
const flags = this.filterConnectFlags(agents);
let colAgents = agents.map(this.convertAgent);
this.validateAgents(colAgents, {allowGrouped: true});
let gAgents = agents.map(this.toGAgent);
this.validateGAgents(gAgents, {allowGrouped: true});
const allAgents = array.flatMap(colAgents, this.expandGroupedAgent);
this.defineAgents(allAgents);
const allGAgents = array.flatMap(gAgents, this.expandGroupedGAgent);
this.defineGAgents(allGAgents);
colAgents = this.expandGroupedAgentConnection(colAgents);
const agentNames = colAgents.map(Agent.getName);
gAgents = this.expandGroupedGAgentConnection(gAgents);
const agentIDs = gAgents.map((gAgent) => gAgent.id);
const implicitBegin = (agents
.filter(Agent.hasFlag('begin', false))
.map(this.convertAgent)
const implicitBeginGAgents = (agents
.filter(PAgent.hasFlag('begin', false))
.map(this.toGAgent)
);
this.addStage(this.setAgentVis(implicitBegin, true, 'box'));
this.addStage(this.setGAgentVis(implicitBeginGAgents, true, 'box'));
const connectStage = {
type: 'connect',
agentNames,
label: this.applyLabelPattern(label),
agentIDs,
label: this.textFormatter(this.applyLabelPattern(label)),
options,
};
this.addParallelStages([
this.setAgentVis(flags.beginAgents, true, 'box', true),
this.setAgentHighlight(flags.startAgents, true, true),
this.setGAgentVis(flags.beginGAgents, true, 'box', true),
this.setGAgentHighlight(flags.startGAgents, true, true),
connectStage,
this.setAgentHighlight(flags.stopAgents, false, true),
this.setAgentVis(flags.endAgents, false, 'cross', true),
this.setGAgentHighlight(flags.stopGAgents, false, true),
this.setGAgentVis(flags.endGAgents, false, 'cross', true),
]);
}
handleNote({type, agents, mode, label}) {
let colAgents = null;
let gAgents = null;
if(agents.length === 0) {
colAgents = NOTE_DEFAULT_AGENTS[type] || [];
gAgents = NOTE_DEFAULT_G_AGENTS[type] || [];
} else {
colAgents = agents.map(this.convertAgent);
gAgents = agents.map(this.toGAgent);
}
this.validateAgents(colAgents, {allowGrouped: true});
colAgents = array.flatMap(colAgents, this.expandGroupedAgent);
const agentNames = colAgents.map(Agent.getName);
const uniqueAgents = new Set(agentNames).size;
this.validateGAgents(gAgents, {allowGrouped: true});
gAgents = array.flatMap(gAgents, this.expandGroupedGAgent);
const agentIDs = gAgents.map((gAgent) => gAgent.id);
const uniqueAgents = new Set(agentIDs).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(this.setGAgentVis(gAgents, true, 'box'));
this.defineGAgents(gAgents);
this.addStage({
type,
agentNames,
agentIDs,
mode,
label,
label: this.textFormatter(label),
});
}
handleAgentDefine({agents}) {
const colAgents = agents.map(this.convertAgent);
this.validateAgents(colAgents);
this.defineAgents(colAgents);
const gAgents = agents.map(this.toGAgent);
this.validateGAgents(gAgents);
this.defineGAgents(gAgents);
}
handleAgentBegin({agents, mode}) {
const colAgents = agents.map(this.convertAgent);
this.validateAgents(colAgents);
this.addStage(this.setAgentVis(colAgents, true, mode, true));
const gAgents = agents.map(this.toGAgent);
this.validateGAgents(gAgents);
this.addStage(this.setGAgentVis(gAgents, true, mode, true));
}
handleAgentEnd({agents, mode}) {
const groupAgents = (agents
.filter((agent) => this.activeGroups.has(agent.name))
const groupPAgents = (agents
.filter((pAgent) => this.activeGroups.has(pAgent.name))
);
const colAgents = (agents
.filter((agent) => !this.activeGroups.has(agent.name))
.map(this.convertAgent)
const gAgents = (agents
.filter((pAgent) => !this.activeGroups.has(pAgent.name))
.map(this.toGAgent)
);
this.validateAgents(colAgents);
this.validateGAgents(gAgents);
this.addParallelStages([
this.setAgentHighlight(colAgents, false),
this.setAgentVis(colAgents, false, mode, true),
...groupAgents.map(this.endGroup),
this.setGAgentHighlight(gAgents, false),
this.setGAgentVis(gAgents, false, mode, true),
...groupPAgents.map(this.endGroup),
]);
}
@ -783,21 +802,46 @@ define(['core/ArrayUtilities'], (array) => {
handler(stage);
} catch(e) {
if(typeof e === 'object' && e.message) {
throw new Error(e.message + ' at line ' + (stage.ln + 1));
e.message += ' at line ' + (stage.ln + 1);
throw e;
}
}
}
generate({stages, meta = {}}) {
_reset() {
this.agentStates.clear();
this.markers.clear();
this.agentAliases.clear();
this.activeGroups.clear();
this.agents.length = 0;
this.gAgents.length = 0;
this.blockCount = 0;
this.nesting.length = 0;
this.labelPattern = [{token: 'label'}];
const globals = this.beginNested('global', '', '', 0);
}
_finalise(globals) {
addBounds(
this.gAgents,
this.currentNest.leftGAgent,
this.currentNest.rightGAgent
);
optimiseStages(globals.stages);
this.gAgents.forEach((gAgent) => {
gAgent.formattedLabel = this.textFormatter(gAgent.id);
});
}
generate({stages, meta = {}}) {
this._reset();
this.textFormatter = meta.textFormatter;
const globals = this.beginNested('global', {
tag: '',
label: '',
name: '',
ln: 0,
});
stages.forEach(this.handleStage);
@ -812,26 +856,21 @@ define(['core/ArrayUtilities'], (array) => {
}
const terminators = meta.terminators || 'none';
this.addParallelStages([
this.setAgentHighlight(this.agents, false),
this.setAgentVis(this.agents, false, terminators),
this.setGAgentHighlight(this.gAgents, false),
this.setGAgentVis(this.gAgents, false, terminators),
]);
addBounds(
this.agents,
this.currentNest.leftAgent,
this.currentNest.rightAgent
);
optimiseStages(globals.stages);
this._finalise(globals);
swapFirstBegin(globals.stages, meta.headers || 'box');
return {
meta: {
title: meta.title,
title: this.textFormatter(meta.title),
theme: meta.theme,
},
agents: this.agents.slice(),
agents: this.gAgents.slice(),
stages: globals.stages,
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
define(() => {
'use strict';
function parseMarkdown(text) {
if(!text) {
return [];
}
const lines = text.split('\n');
const result = [];
const attrs = null;
lines.forEach((line) => {
result.push([{text: line, attrs}]);
});
return result;
}
return parseMarkdown;
});

View File

@ -0,0 +1,51 @@
defineDescribe('Markdown Parser', [
'./MarkdownParser',
'svg/SVGTextBlock',
'svg/SVGUtilities',
], (
parser,
SVGTextBlock,
svg
) => {
'use strict';
it('converts simple text', () => {
const formatted = parser('hello everybody');
expect(formatted).toEqual([[{text: 'hello everybody', attrs: null}]]);
});
it('produces an empty array given an empty input', () => {
const formatted = parser('');
expect(formatted).toEqual([]);
});
it('converts multiline text', () => {
const formatted = parser('hello\neverybody');
expect(formatted).toEqual([
[{text: 'hello', attrs: null}],
[{text: 'everybody', attrs: null}],
]);
});
describe('SVGTextBlock interaction', () => {
let hold = null;
let block = null;
beforeEach(() => {
hold = svg.makeContainer();
document.body.appendChild(hold);
block = new SVGTextBlock(hold, {attrs: {'font-size': 12}});
});
afterEach(() => {
document.body.removeChild(hold);
});
it('produces a format compatible with SVGTextBlock', () => {
const formatted = parser('hello everybody');
block.set({formatted});
expect(hold.children.length).toEqual(1);
expect(hold.children[0].innerHTML).toEqual('hello everybody');
});
});
});

View File

@ -1,20 +1,35 @@
define([
'core/ArrayUtilities',
'./Tokeniser',
'./MarkdownParser',
'./LabelPatternParser',
'./CodeMirrorHints',
], (
array,
Tokeniser,
labelPatternParser,
CMHints
markdownParser,
labelPatternParser
) => {
'use strict';
const BLOCK_TYPES = {
'if': {type: 'block begin', mode: 'if', skip: []},
'else': {type: 'block split', mode: 'else', skip: ['if']},
'repeat': {type: 'block begin', mode: 'repeat', skip: []},
'if': {
type: 'block begin',
blockType: 'if',
tag: 'if',
skip: [],
},
'else': {
type: 'block split',
blockType: 'else',
tag: 'else',
skip: ['if'],
},
'repeat': {
type: 'block begin',
blockType: 'repeat',
tag: 'repeat',
skip: [],
},
};
const CONNECT_TYPES = ((() => {
@ -314,7 +329,8 @@ define([
skip = skipOver(line, skip, [':']);
return {
type: type.type,
mode: type.mode,
blockType: type.blockType,
tag: type.tag,
label: joinLabel(line, skip),
};
},
@ -345,7 +361,8 @@ define([
return {
type: 'group begin',
agents,
mode: 'ref',
blockType: 'ref',
tag: 'ref',
label: def.name,
alias: def.alias,
};
@ -480,10 +497,6 @@ define([
);
}
getCodeMirrorHints() {
return CMHints.getHints;
}
parseLines(lines) {
const result = {
meta: {
@ -491,6 +504,7 @@ define([
theme: '',
terminators: 'none',
headers: 'box',
textFormatter: markdownParser,
},
stages: [],
};

View File

@ -6,26 +6,30 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
const PARSED = {
blockBegin: ({
ln = jasmine.anything(),
mode = jasmine.anything(),
blockType = jasmine.anything(),
tag = jasmine.anything(),
label = jasmine.anything(),
} = {}) => {
return {
type: 'block begin',
ln,
mode,
blockType,
tag,
label,
};
},
blockSplit: ({
ln = jasmine.anything(),
mode = jasmine.anything(),
blockType = jasmine.anything(),
tag = jasmine.anything(),
label = jasmine.anything(),
} = {}) => {
return {
type: 'block split',
ln,
mode,
blockType,
tag,
label,
};
},
@ -73,6 +77,7 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
theme: '',
terminators: 'none',
headers: 'box',
textFormatter: jasmine.anything(),
},
stages: [],
});
@ -98,6 +103,11 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
expect(parsed.meta.headers).toEqual('bar');
});
it('propagates a function which can be used to format text', () => {
const parsed = parser.parse('title foo');
expect(parsed.meta.textFormatter).toEqual(jasmine.any(Function));
});
it('reads multiple tokens as one when reading values', () => {
const parsed = parser.parse('title foo bar');
expect(parsed.meta.title).toEqual('foo bar');
@ -435,7 +445,8 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
type: 'group begin',
ln: jasmine.anything(),
agents: [],
mode: 'ref',
blockType: 'ref',
tag: 'ref',
label: 'Foo bar',
alias: 'baz',
},
@ -446,7 +457,8 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
{name: 'A', alias: '', flags: []},
{name: 'B', alias: '', flags: []},
],
mode: 'ref',
blockType: 'ref',
tag: 'ref',
label: 'Foo bar',
alias: 'baz',
},
@ -518,12 +530,24 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
'end\n'
);
expect(parsed.stages).toEqual([
PARSED.blockBegin({mode: 'if', label: 'something happens'}),
PARSED.blockBegin({
blockType: 'if',
tag: 'if',
label: 'something happens',
}),
PARSED.connect(['A', 'B']),
PARSED.blockSplit({mode: 'else', label: 'something else'}),
PARSED.blockSplit({
blockType: 'else',
tag: 'else',
label: 'something else',
}),
PARSED.connect(['A', 'C']),
PARSED.connect(['C', 'B']),
PARSED.blockSplit({mode: 'else', label: ''}),
PARSED.blockSplit({
blockType: 'else',
tag: 'else',
label: '',
}),
PARSED.connect(['A', 'D']),
PARSED.blockEnd(),
]);
@ -532,7 +556,11 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
it('converts loop blocks', () => {
const parsed = parser.parse('repeat until something');
expect(parsed.stages).toEqual([
PARSED.blockBegin({mode: 'repeat', label: 'until something'}),
PARSED.blockBegin({
blockType: 'repeat',
tag: 'repeat',
label: 'until something',
}),
]);
});

View File

@ -22,11 +22,11 @@ define([
/* jshint +W072 */
'use strict';
function findExtremes(agentInfos, agentNames) {
function findExtremes(agentInfos, agentIDs) {
let min = null;
let max = null;
agentNames.forEach((name) => {
const info = agentInfos.get(name);
agentIDs.forEach((id) => {
const info = agentInfos.get(id);
if(min === null || info.index < min.index) {
min = info;
}
@ -35,8 +35,8 @@ define([
}
});
return {
left: min.label,
right: max.label,
left: min.id,
right: max.id,
};
}
@ -158,23 +158,23 @@ define([
return namespacedName;
}
addSeparation(agentName1, agentName2, dist) {
const info1 = this.agentInfos.get(agentName1);
const info2 = this.agentInfos.get(agentName2);
addSeparation(agentID1, agentID2, dist) {
const info1 = this.agentInfos.get(agentID1);
const info2 = this.agentInfos.get(agentID2);
const d1 = info1.separations.get(agentName2) || 0;
info1.separations.set(agentName2, Math.max(d1, dist));
const d1 = info1.separations.get(agentID2) || 0;
info1.separations.set(agentID2, Math.max(d1, dist));
const d2 = info2.separations.get(agentName1) || 0;
info2.separations.set(agentName1, Math.max(d2, dist));
const d2 = info2.separations.get(agentID1) || 0;
info2.separations.set(agentID1, Math.max(d2, dist));
}
separationStage(stage) {
const agentSpaces = new Map();
const agentNames = this.visibleAgents.slice();
const agentIDs = this.visibleAgentIDs.slice();
const addSpacing = (agentName, {left, right}) => {
const current = agentSpaces.get(agentName);
const addSpacing = (agentID, {left, right}) => {
const current = agentSpaces.get(agentID);
current.left = Math.max(current.left, left);
current.right = Math.max(current.right, right);
};
@ -182,12 +182,12 @@ define([
this.agentInfos.forEach((agentInfo) => {
const rad = agentInfo.currentRad;
agentInfo.currentMaxRad = rad;
agentSpaces.set(agentInfo.label, {left: rad, right: rad});
agentSpaces.set(agentInfo.id, {left: rad, right: rad});
});
const env = {
theme: this.theme,
agentInfos: this.agentInfos,
visibleAgents: this.visibleAgents,
visibleAgentIDs: this.visibleAgentIDs,
textSizer: this.sizer,
addSpacing,
addSeparation: this.addSeparation,
@ -200,33 +200,33 @@ define([
}
component.separationPre(stage, env);
component.separation(stage, env);
array.mergeSets(agentNames, this.visibleAgents);
array.mergeSets(agentIDs, this.visibleAgentIDs);
agentNames.forEach((agentNameR) => {
const infoR = this.agentInfos.get(agentNameR);
const sepR = agentSpaces.get(agentNameR);
agentIDs.forEach((agentIDR) => {
const infoR = this.agentInfos.get(agentIDR);
const sepR = agentSpaces.get(agentIDR);
infoR.maxRPad = Math.max(infoR.maxRPad, sepR.right);
infoR.maxLPad = Math.max(infoR.maxLPad, sepR.left);
agentNames.forEach((agentNameL) => {
const infoL = this.agentInfos.get(agentNameL);
agentIDs.forEach((agentIDL) => {
const infoL = this.agentInfos.get(agentIDL);
if(infoL.index >= infoR.index) {
return;
}
const sepL = agentSpaces.get(agentNameL);
const sepL = agentSpaces.get(agentIDL);
this.addSeparation(
agentNameR,
agentNameL,
agentIDR,
agentIDL,
sepR.left + sepL.right + this.theme.agentMargin
);
});
});
}
checkAgentRange(agentNames, topY = 0) {
if(agentNames.length === 0) {
checkAgentRange(agentIDs, topY = 0) {
if(agentIDs.length === 0) {
return topY;
}
const {left, right} = findExtremes(this.agentInfos, agentNames);
const {left, right} = findExtremes(this.agentInfos, agentIDs);
const leftX = this.agentInfos.get(left).x;
const rightX = this.agentInfos.get(right).x;
let baseY = topY;
@ -238,11 +238,11 @@ define([
return baseY;
}
markAgentRange(agentNames, y) {
if(agentNames.length === 0) {
markAgentRange(agentIDs, y) {
if(agentIDs.length === 0) {
return;
}
const {left, right} = findExtremes(this.agentInfos, agentNames);
const {left, right} = findExtremes(this.agentInfos, agentIDs);
const leftX = this.agentInfos.get(left).x;
const rightX = this.agentInfos.get(right).x;
this.agentInfos.forEach((agentInfo) => {
@ -293,10 +293,10 @@ define([
};
const component = this.components.get(stage.type);
const result = component.renderPre(stage, envPre);
const {topShift, agentNames, asynchronousY} =
const {topShift, agentIDs, asynchronousY} =
BaseComponent.cleanRenderPreResult(result, this.currentY);
const topY = this.checkAgentRange(agentNames, asynchronousY);
const topY = this.checkAgentRange(agentIDs, asynchronousY);
const eventOut = () => {
this.trigger('mouseout');
@ -332,8 +332,8 @@ define([
textSizer: this.sizer,
SVGTextBlockClass: this.SVGTextBlockClass,
state: this.state,
drawAgentLine: (agentName, toY, andStop = false) => {
const agentInfo = this.agentInfos.get(agentName);
drawAgentLine: (agentID, toY, andStop = false) => {
const agentInfo = this.agentInfos.get(agentID);
this.drawAgentLine(agentInfo, toY);
agentInfo.latestYStart = andStop ? null : toY;
},
@ -343,7 +343,7 @@ define([
};
const bottomY = Math.max(topY, component.render(stage, env) || 0);
this.markAgentRange(agentNames, bottomY);
this.markAgentRange(agentIDs, bottomY);
this.currentY = bottomY;
}
@ -379,7 +379,7 @@ define([
agentInfo.x = currentX;
});
this.agentInfos.forEach(({label, x, maxRPad, maxLPad}) => {
this.agentInfos.forEach(({x, maxRPad, maxLPad}) => {
this.minX = Math.min(this.minX, x - maxLPad);
this.maxX = Math.max(this.maxX, x + maxRPad);
});
@ -388,8 +388,9 @@ define([
buildAgentInfos(agents, stages) {
this.agentInfos = new Map();
agents.forEach((agent, index) => {
this.agentInfos.set(agent.name, {
label: agent.name,
this.agentInfos.set(agent.id, {
id: agent.id,
formattedLabel: agent.formattedLabel,
anchorRight: agent.anchorRight,
index,
x: null,
@ -403,7 +404,7 @@ define([
});
});
this.visibleAgents = ['[', ']'];
this.visibleAgentIDs = ['[', ']'];
stages.forEach(this.separationStage);
this.positionAgents();
@ -497,7 +498,7 @@ define([
this.title.set({
attrs: this.theme.titleAttrs,
text: sequence.meta.title,
formatted: sequence.meta.title,
});
this.minX = 0;
@ -534,8 +535,8 @@ define([
return this.themes.get('');
}
getAgentX(name) {
return this.agentInfos.get(name).x;
getAgentX(id) {
return this.agentInfos.get(id).x;
}
svg() {

View File

@ -26,10 +26,10 @@ defineDescribe('Sequence Renderer', [
});
const GENERATED = {
connect: (agentNames, label = '') => {
connect: (agentIDs, label = []) => {
return {
type: 'connect',
agentNames,
agentIDs,
label,
options: {
line: 'solid',
@ -40,15 +40,35 @@ defineDescribe('Sequence Renderer', [
},
};
function format(text) {
if(!text) {
return [];
}
return [[{text}]];
}
describe('.render', () => {
it('populates the SVG with content', () => {
renderer.render({
meta: {title: 'Title'},
meta: {title: format('Title')},
agents: [
{name: '[', anchorRight: true},
{name: 'Col 1', anchorRight: false},
{name: 'Col 2', anchorRight: false},
{name: ']', anchorRight: false},
{
id: '[',
formattedLabel: null,
anchorRight: true,
}, {
id: 'Col 1',
formattedLabel: format('Col 1!'),
anchorRight: false,
}, {
id: 'Col 2',
formattedLabel: format('Col 2!'),
anchorRight: false,
}, {
id: ']',
formattedLabel: null,
anchorRight: false,
},
],
stages: [],
});
@ -63,17 +83,17 @@ defineDescribe('Sequence Renderer', [
*/
renderer.render({
meta: {title: ''},
meta: {title: []},
agents: [
{name: '[', anchorRight: true},
{name: 'A', anchorRight: false},
{name: 'B', anchorRight: false},
{name: ']', anchorRight: false},
{id: '[', formattedLabel: null, anchorRight: true},
{id: 'A', formattedLabel: format('A!'), anchorRight: false},
{id: 'B', formattedLabel: format('B!'), anchorRight: false},
{id: ']', formattedLabel: null, anchorRight: false},
],
stages: [
{type: 'agent begin', agentNames: ['A', 'B'], mode: 'box'},
{type: 'agent begin', agentIDs: ['A', 'B'], mode: 'box'},
GENERATED.connect(['A', 'B']),
{type: 'agent end', agentNames: ['A', 'B'], mode: 'none'},
{type: 'agent end', agentIDs: ['A', 'B'], mode: 'none'},
],
});
@ -93,18 +113,18 @@ defineDescribe('Sequence Renderer', [
*/
renderer.render({
meta: {title: ''},
meta: {title: []},
agents: [
{name: '[', anchorRight: true},
{name: 'A', anchorRight: false},
{name: 'B', anchorRight: false},
{name: 'C', anchorRight: false},
{name: ']', anchorRight: false},
{id: '[', formattedLabel: null, anchorRight: true},
{id: 'A', formattedLabel: format('A!'), anchorRight: false},
{id: 'B', formattedLabel: format('B!'), anchorRight: false},
{id: 'C', formattedLabel: format('C!'), anchorRight: false},
{id: ']', formattedLabel: null, anchorRight: false},
],
stages: [
{
type: 'agent begin',
agentNames: ['A', 'B', 'C'],
agentIDs: ['A', 'B', 'C'],
mode: 'box',
},
GENERATED.connect(['[', 'A']),
@ -113,7 +133,7 @@ defineDescribe('Sequence Renderer', [
GENERATED.connect(['C', ']']),
{
type: 'agent end',
agentNames: ['A', 'B', 'C'],
agentIDs: ['A', 'B', 'C'],
mode: 'none',
},
],
@ -142,25 +162,25 @@ defineDescribe('Sequence Renderer', [
*/
renderer.render({
meta: {title: ''},
meta: {title: []},
agents: [
{name: '[', anchorRight: true},
{name: 'A', anchorRight: false},
{name: 'B', anchorRight: false},
{name: 'C', anchorRight: false},
{name: 'D', anchorRight: false},
{name: ']', anchorRight: false},
{id: '[', formattedLabel: null, anchorRight: true},
{id: 'A', formattedLabel: format('A!'), anchorRight: false},
{id: 'B', formattedLabel: format('B!'), anchorRight: false},
{id: 'C', formattedLabel: format('C!'), anchorRight: false},
{id: 'D', formattedLabel: format('D!'), anchorRight: false},
{id: ']', formattedLabel: null, anchorRight: false},
],
stages: [
{type: 'agent begin', agentNames: ['A', 'B'], mode: 'box'},
GENERATED.connect(['A', 'B'], 'short'),
{type: 'agent end', agentNames: ['B'], mode: 'cross'},
{type: 'agent begin', agentNames: ['C'], mode: 'box'},
GENERATED.connect(['A', 'C'], 'long description here'),
{type: 'agent end', agentNames: ['C'], mode: 'cross'},
{type: 'agent begin', agentNames: ['D'], mode: 'box'},
GENERATED.connect(['A', 'D'], 'short again'),
{type: 'agent end', agentNames: ['A', 'D'], mode: 'cross'},
{type: 'agent begin', agentIDs: ['A', 'B'], mode: 'box'},
GENERATED.connect(['A', 'B'], format('short')),
{type: 'agent end', agentIDs: ['B'], mode: 'cross'},
{type: 'agent begin', agentIDs: ['C'], mode: 'box'},
GENERATED.connect(['A', 'C'], format('long description')),
{type: 'agent end', agentIDs: ['C'], mode: 'cross'},
{type: 'agent begin', agentIDs: ['D'], mode: 'box'},
GENERATED.connect(['A', 'D'], format('short again')),
{type: 'agent end', agentIDs: ['A', 'D'], mode: 'cross'},
],
});

View File

@ -5,6 +5,7 @@ define([
'./Generator',
'./Renderer',
'./Exporter',
'./CodeMirrorHints',
'./themes/BaseTheme',
'./themes/Basic',
'./themes/Monospace',
@ -16,6 +17,7 @@ define([
Generator,
Renderer,
Exporter,
CMHints,
BaseTheme,
BasicTheme,
MonospaceTheme,
@ -36,14 +38,13 @@ define([
const SharedParser = new Parser();
const SharedGenerator = new Generator();
const CMMode = SharedParser.getCodeMirrorMode();
const CMHints = SharedParser.getCodeMirrorHints();
function registerCodeMirrorMode(CodeMirror, modeName = 'sequence') {
if(!CodeMirror) {
CodeMirror = window.CodeMirror;
}
CodeMirror.defineMode(modeName, () => CMMode);
CodeMirror.registerHelper('hint', modeName, CMHints);
CodeMirror.registerHelper('hint', modeName, CMHints.getHints);
}
function addTheme(theme) {

View File

@ -12,10 +12,10 @@ define([
'use strict';
class CapBox {
separation({label}, env) {
separation({formattedLabel}, env) {
const config = env.theme.agentCap.box;
const width = (
env.textSizer.measure(config.labelAttrs, label).width +
env.textSizer.measure(config.labelAttrs, formattedLabel).width +
config.padding.left +
config.padding.right
);
@ -27,20 +27,20 @@ define([
};
}
topShift({label}, env) {
topShift({formattedLabel}, env) {
const config = env.theme.agentCap.box;
const height = (
env.textSizer.measureHeight(config.labelAttrs, label) +
env.textSizer.measureHeight(config.labelAttrs, formattedLabel) +
config.padding.top +
config.padding.bottom
);
return Math.max(0, height - config.arrowBottom);
}
render(y, {x, label}, env) {
render(y, {x, formattedLabel}, env) {
const config = env.theme.agentCap.box;
const clickable = env.makeRegion();
const {width, height} = SVGShapes.renderBoxedText(label, {
const {width, height} = SVGShapes.renderBoxedText(formattedLabel, {
x,
y,
padding: config.padding,
@ -105,10 +105,10 @@ define([
}
class CapBar {
separation({label}, env) {
separation({formattedLabel}, env) {
const config = env.theme.agentCap.box;
const width = (
env.textSizer.measure(config.labelAttrs, label).width +
env.textSizer.measure(config.labelAttrs, formattedLabel).width +
config.padding.left +
config.padding.right
);
@ -125,17 +125,17 @@ define([
return config.height / 2;
}
render(y, {x, label}, env) {
const configB = env.theme.agentCap.box;
const config = env.theme.agentCap.bar;
render(y, {x, formattedLabel}, env) {
const boxCfg = env.theme.agentCap.box;
const barCfg = env.theme.agentCap.bar;
const width = (
env.textSizer.measure(configB.labelAttrs, label).width +
configB.padding.left +
configB.padding.right
env.textSizer.measure(boxCfg.labelAttrs, formattedLabel).width +
boxCfg.padding.left +
boxCfg.padding.right
);
const height = config.height;
const height = barCfg.height;
env.shapeLayer.appendChild(config.render({
env.shapeLayer.appendChild(barCfg.render({
x: x - width / 2,
y,
width,
@ -172,7 +172,7 @@ define([
return isBegin ? config.height : 0;
}
render(y, {x, label}, env, isBegin) {
render(y, {x}, env, isBegin) {
const config = env.theme.agentCap.fade;
const ratio = config.height / (config.height + config.extend);
@ -266,12 +266,12 @@ define([
this.begin = begin;
}
separationPre({mode, agentNames}, env) {
agentNames.forEach((name) => {
const agentInfo = env.agentInfos.get(name);
separationPre({mode, agentIDs}, env) {
agentIDs.forEach((id) => {
const agentInfo = env.agentInfos.get(id);
const cap = AGENT_CAPS[mode];
const sep = cap.separation(agentInfo, env, this.begin);
env.addSpacing(name, sep);
env.addSpacing(id, sep);
agentInfo.currentMaxRad = Math.max(
agentInfo.currentMaxRad,
sep.radius
@ -279,18 +279,18 @@ define([
});
}
separation({mode, agentNames}, env) {
separation({mode, agentIDs}, env) {
if(this.begin) {
array.mergeSets(env.visibleAgents, agentNames);
array.mergeSets(env.visibleAgentIDs, agentIDs);
} else {
array.removeAll(env.visibleAgents, agentNames);
array.removeAll(env.visibleAgentIDs, agentIDs);
}
}
renderPre({mode, agentNames}, env) {
renderPre({mode, agentIDs}, env) {
let maxTopShift = 0;
agentNames.forEach((name) => {
const agentInfo = env.agentInfos.get(name);
agentIDs.forEach((id) => {
const agentInfo = env.agentInfos.get(id);
const cap = AGENT_CAPS[mode];
const topShift = cap.topShift(agentInfo, env, this.begin);
maxTopShift = Math.max(maxTopShift, topShift);
@ -299,15 +299,15 @@ define([
agentInfo.currentMaxRad = Math.max(agentInfo.currentMaxRad, r);
});
return {
agentNames,
agentIDs,
topShift: maxTopShift,
};
}
render({mode, agentNames}, env) {
render({mode, agentIDs}, env) {
let maxEnd = 0;
agentNames.forEach((name) => {
const agentInfo = env.agentInfos.get(name);
agentIDs.forEach((id) => {
const agentInfo = env.agentInfos.get(id);
const cap = AGENT_CAPS[mode];
const topShift = cap.topShift(agentInfo, env, this.begin);
const y0 = env.primaryY - topShift;
@ -319,9 +319,9 @@ define([
);
maxEnd = Math.max(maxEnd, y0 + shifts.height);
if(this.begin) {
env.drawAgentLine(name, y0 + shifts.lineBottom);
env.drawAgentLine(id, y0 + shifts.lineBottom);
} else {
env.drawAgentLine(name, y0 + shifts.lineTop, true);
env.drawAgentLine(id, y0 + shifts.lineTop, true);
}
});
return maxEnd + env.theme.actionMargin;

View File

@ -6,28 +6,28 @@ define(['./BaseComponent'], (BaseComponent) => {
return highlighted ? env.theme.agentLineHighlightRadius : 0;
}
separationPre({agentNames, highlighted}, env) {
separationPre({agentIDs, highlighted}, env) {
const r = this.radius(highlighted, env);
agentNames.forEach((name) => {
const agentInfo = env.agentInfos.get(name);
agentIDs.forEach((id) => {
const agentInfo = env.agentInfos.get(id);
agentInfo.currentRad = r;
agentInfo.currentMaxRad = Math.max(agentInfo.currentMaxRad, r);
});
}
renderPre({agentNames, highlighted}, env) {
renderPre({agentIDs, highlighted}, env) {
const r = this.radius(highlighted, env);
agentNames.forEach((name) => {
const agentInfo = env.agentInfos.get(name);
agentIDs.forEach((id) => {
const agentInfo = env.agentInfos.get(id);
agentInfo.currentMaxRad = Math.max(agentInfo.currentMaxRad, r);
});
}
render({agentNames, highlighted}, env) {
render({agentIDs, highlighted}, env) {
const r = this.radius(highlighted, env);
agentNames.forEach((name) => {
env.drawAgentLine(name, env.primaryY);
env.agentInfos.get(name).currentRad = r;
agentIDs.forEach((id) => {
env.drawAgentLine(id, env.primaryY);
env.agentInfos.get(id).currentRad = r;
});
return env.primaryY + env.theme.actionMargin;
}

View File

@ -28,7 +28,7 @@ defineDescribe('AgentHighlight', [
theme,
agentInfos,
};
highlight.separationPre({agentNames: ['foo'], highlighted: true}, env);
highlight.separationPre({agentIDs: ['foo'], highlighted: true}, env);
expect(agentInfo.currentRad).toEqual(2);
expect(agentInfo.currentMaxRad).toEqual(2);
});
@ -41,7 +41,7 @@ defineDescribe('AgentHighlight', [
theme,
agentInfos,
};
highlight.separationPre({agentNames: ['foo'], highlighted: true}, env);
highlight.separationPre({agentIDs: ['foo'], highlighted: true}, env);
expect(agentInfo.currentRad).toEqual(2);
expect(agentInfo.currentMaxRad).toEqual(3);
});
@ -54,7 +54,7 @@ defineDescribe('AgentHighlight', [
theme,
agentInfos,
};
highlight.separationPre({agentNames: ['foo'], highlighted: false}, env);
highlight.separationPre({agentIDs: ['foo'], highlighted: false}, env);
expect(agentInfo.currentRad).toEqual(0);
expect(agentInfo.currentMaxRad).toEqual(1);
});

View File

@ -12,7 +12,7 @@ define(() => {
separationPre(/*stage, {
theme,
agentInfos,
visibleAgents,
visibleAgentIDs,
textSizer,
addSpacing,
addSeparation,
@ -24,7 +24,7 @@ define(() => {
separation(/*stage, {
theme,
agentInfos,
visibleAgents,
visibleAgentIDs,
textSizer,
addSpacing,
addSeparation,
@ -40,7 +40,7 @@ define(() => {
state,
components,
}*/) {
// return {topShift, agentNames, asynchronousY}
// return {topShift, agentIDs, asynchronousY}
}
render(/*stage, {
@ -64,12 +64,12 @@ define(() => {
BaseComponent.cleanRenderPreResult = ({
topShift = 0,
agentNames = [],
agentIDs = [],
asynchronousY = null,
} = {}, currentY = null) => {
return {
topShift,
agentNames,
agentIDs,
asynchronousY: (asynchronousY !== null) ? asynchronousY : currentY,
};
};

View File

@ -12,13 +12,13 @@ define([
'use strict';
class BlockSplit extends BaseComponent {
separation({left, right, mode, label}, env) {
separation({left, right, tag, label}, env) {
const blockInfo = env.state.blocks.get(left);
const config = env.theme.getBlock(blockInfo.mode).section;
const config = env.theme.getBlock(blockInfo.type).section;
const width = (
env.textSizer.measure(config.mode.labelAttrs, mode).width +
config.mode.padding.left +
config.mode.padding.right +
env.textSizer.measure(config.tag.labelAttrs, tag).width +
config.tag.padding.left +
config.tag.padding.right +
env.textSizer.measure(config.label.labelAttrs, label).width +
config.label.padding.left +
config.label.padding.right
@ -28,13 +28,13 @@ define([
renderPre({left, right}) {
return {
agentNames: [left, right],
agentIDs: [left, right],
};
}
render({left, right, mode, label}, env, first = false) {
render({left, right, tag, label}, env, first = false) {
const blockInfo = env.state.blocks.get(left);
const config = env.theme.getBlock(blockInfo.mode);
const config = env.theme.getBlock(blockInfo.type);
const agentInfoL = env.agentInfos.get(left);
const agentInfoR = env.agentInfos.get(right);
@ -46,20 +46,20 @@ define([
const clickable = env.makeRegion();
const modeRender = SVGShapes.renderBoxedText(mode, {
const tagRender = SVGShapes.renderBoxedText(tag, {
x: agentInfoL.x,
y,
padding: config.section.mode.padding,
boxAttrs: config.section.mode.boxAttrs,
boxRenderer: config.section.mode.boxRenderer,
labelAttrs: config.section.mode.labelAttrs,
padding: config.section.tag.padding,
boxAttrs: config.section.tag.boxAttrs,
boxRenderer: config.section.tag.boxRenderer,
labelAttrs: config.section.tag.labelAttrs,
boxLayer: blockInfo.hold,
labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass,
});
const labelRender = SVGShapes.renderBoxedText(label, {
x: agentInfoL.x + modeRender.width,
x: agentInfoL.x + tagRender.width,
y,
padding: config.section.label.padding,
boxAttrs: {'fill': '#000000'},
@ -69,7 +69,7 @@ define([
SVGTextBlockClass: env.SVGTextBlockClass,
});
const labelHeight = Math.max(modeRender.height, labelRender.height);
const labelHeight = Math.max(tagRender.height, labelRender.height);
clickable.insertBefore(svg.make('rect', {
'x': agentInfoL.x,
@ -102,11 +102,13 @@ define([
}
storeBlockInfo(stage, env) {
env.state.blocks.set(stage.left, {
mode: stage.mode,
const blockInfo = {
type: stage.blockType,
hold: null,
startY: null,
});
};
env.state.blocks.set(stage.left, blockInfo);
return blockInfo;
}
separationPre(stage, env) {
@ -114,17 +116,17 @@ define([
}
separation(stage, env) {
array.mergeSets(env.visibleAgents, [stage.left, stage.right]);
array.mergeSets(env.visibleAgentIDs, [stage.left, stage.right]);
super.separation(stage, env);
}
renderPre(stage, env) {
this.storeBlockInfo(stage, env);
const blockInfo = this.storeBlockInfo(stage, env);
const config = env.theme.getBlock(stage.mode);
const config = env.theme.getBlock(blockInfo.type);
return {
agentNames: [stage.left, stage.right],
agentIDs: [stage.left, stage.right],
topShift: config.margin.top,
};
}
@ -143,22 +145,22 @@ define([
class BlockEnd extends BaseComponent {
separation({left, right}, env) {
array.removeAll(env.visibleAgents, [left, right]);
array.removeAll(env.visibleAgentIDs, [left, right]);
}
renderPre({left, right}, env) {
const blockInfo = env.state.blocks.get(left);
const config = env.theme.getBlock(blockInfo.mode);
const config = env.theme.getBlock(blockInfo.type);
return {
agentNames: [left, right],
agentIDs: [left, right],
topShift: config.section.padding.bottom,
};
}
render({left, right}, env) {
const blockInfo = env.state.blocks.get(left);
const config = env.theme.getBlock(blockInfo.mode);
const config = env.theme.getBlock(blockInfo.type);
const agentInfoL = env.agentInfos.get(left);
const agentInfoR = env.agentInfos.get(right);

View File

@ -77,7 +77,7 @@ define([
];
class Connect extends BaseComponent {
separation({label, agentNames, options}, env) {
separation({label, agentIDs, options}, env) {
const config = env.theme.connect;
const lArrow = ARROWHEADS[options.left];
@ -90,9 +90,9 @@ define([
labelWidth += config.label.padding * 2;
}
const info1 = env.agentInfos.get(agentNames[0]);
if(agentNames[0] === agentNames[1]) {
env.addSpacing(agentNames[0], {
const info1 = env.agentInfos.get(agentIDs[0]);
if(agentIDs[0] === agentIDs[1]) {
env.addSpacing(agentIDs[0], {
left: 0,
right: (
info1.currentMaxRad +
@ -104,10 +104,10 @@ define([
),
});
} else {
const info2 = env.agentInfos.get(agentNames[1]);
const info2 = env.agentInfos.get(agentIDs[1]);
env.addSeparation(
agentNames[0],
agentNames[1],
agentIDs[0],
agentIDs[1],
info1.currentMaxRad +
info2.currentMaxRad +
@ -120,10 +120,10 @@ define([
}
}
renderSelfConnect({label, agentNames, options}, env) {
renderSelfConnect({label, agentIDs, options}, env) {
/* jshint -W071 */ // TODO: find appropriate abstractions
const config = env.theme.connect;
const from = env.agentInfos.get(agentNames[0]);
const from = env.agentInfos.get(agentIDs[0]);
const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right];
@ -195,10 +195,10 @@ define([
);
}
renderSimpleConnect({label, agentNames, options}, env) {
renderSimpleConnect({label, agentIDs, options}, env) {
const config = env.theme.connect;
const from = env.agentInfos.get(agentNames[0]);
const to = env.agentInfos.get(agentNames[1]);
const from = env.agentInfos.get(agentIDs[0]);
const to = env.agentInfos.get(agentIDs[1]);
const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right];
@ -260,7 +260,7 @@ define([
);
}
renderPre({label, agentNames, options}, env) {
renderPre({label, agentIDs, options}, env) {
const config = env.theme.connect;
const lArrow = ARROWHEADS[options.left];
@ -273,18 +273,18 @@ define([
);
let arrowH = lArrow.height(env.theme);
if(agentNames[0] !== agentNames[1]) {
if(agentIDs[0] !== agentIDs[1]) {
arrowH = Math.max(arrowH, rArrow.height(env.theme));
}
return {
agentNames,
agentIDs,
topShift: Math.max(arrowH / 2, height),
};
}
render(stage, env) {
if(stage.agentNames[0] === stage.agentNames[1]) {
if(stage.agentIDs[0] === stage.agentIDs[1]) {
return this.renderSelfConnect(stage, env);
} else {
return this.renderSimpleConnect(stage, env);

View File

@ -1,11 +1,11 @@
define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
'use strict';
function findExtremes(agentInfos, agentNames) {
function findExtremes(agentInfos, agentIDs) {
let min = null;
let max = null;
agentNames.forEach((name) => {
const info = agentInfos.get(name);
agentIDs.forEach((id) => {
const info = agentInfos.get(id);
if(min === null || info.index < min.index) {
min = info;
}
@ -14,14 +14,14 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
}
});
return {
left: min.label,
right: max.label,
left: min.id,
right: max.id,
};
}
class NoteComponent extends BaseComponent {
renderPre({agentNames}) {
return {agentNames};
renderPre({agentIDs}) {
return {agentIDs};
}
renderNote({
@ -39,7 +39,7 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
const y = env.topY + config.margin.top + config.padding.top;
const labelNode = new env.SVGTextBlockClass(clickable, {
attrs: config.labelAttrs,
text: label,
formatted: label,
y,
});
@ -105,7 +105,7 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
}
class NoteOver extends NoteComponent {
separation({agentNames, mode, label}, env) {
separation({agentIDs, mode, label}, env) {
const config = env.theme.getNote(mode);
const width = (
env.textSizer.measure(config.labelAttrs, label).width +
@ -113,7 +113,7 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
config.padding.right
);
const {left, right} = findExtremes(env.agentInfos, agentNames);
const {left, right} = findExtremes(env.agentInfos, agentIDs);
const infoL = env.agentInfos.get(left);
const infoR = env.agentInfos.get(right);
if(infoL !== infoR) {
@ -132,10 +132,10 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
}
}
render({agentNames, mode, label}, env) {
render({agentIDs, mode, label}, env) {
const config = env.theme.getNote(mode);
const {left, right} = findExtremes(env.agentInfos, agentNames);
const {left, right} = findExtremes(env.agentInfos, agentIDs);
const infoL = env.agentInfos.get(left);
const infoR = env.agentInfos.get(right);
if(infoL !== infoR) {
@ -164,9 +164,9 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
this.isRight = isRight;
}
separation({agentNames, mode, label}, env) {
separation({agentIDs, mode, label}, env) {
const config = env.theme.getNote(mode);
const {left, right} = findExtremes(env.agentInfos, agentNames);
const {left, right} = findExtremes(env.agentInfos, agentIDs);
const width = (
env.textSizer.measure(config.labelAttrs, label).width +
config.padding.left +
@ -190,9 +190,9 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
}
}
render({agentNames, mode, label}, env) {
render({agentIDs, mode, label}, env) {
const config = env.theme.getNote(mode);
const {left, right} = findExtremes(env.agentInfos, agentNames);
const {left, right} = findExtremes(env.agentInfos, agentIDs);
if(this.isRight) {
const info = env.agentInfos.get(right);
const x0 = info.x + info.currentMaxRad + config.margin.left;
@ -216,9 +216,9 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
}
class NoteBetween extends NoteComponent {
separation({agentNames, mode, label}, env) {
separation({agentIDs, mode, label}, env) {
const config = env.theme.getNote(mode);
const {left, right} = findExtremes(env.agentInfos, agentNames);
const {left, right} = findExtremes(env.agentInfos, agentIDs);
const infoL = env.agentInfos.get(left);
const infoR = env.agentInfos.get(right);
@ -236,8 +236,8 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
);
}
render({agentNames, mode, label}, env) {
const {left, right} = findExtremes(env.agentInfos, agentNames);
render({agentIDs, mode, label}, env) {
const {left, right} = findExtremes(env.agentInfos, agentIDs);
const infoL = env.agentInfos.get(left);
const infoR = env.agentInfos.get(right);
const xMid = (

View File

@ -18,10 +18,10 @@ define([
}
function mergeResults(a, b) {
array.mergeSets(a.agentNames, b.agentNames);
array.mergeSets(a.agentIDs, b.agentIDs);
return {
topShift: Math.max(a.topShift, b.topShift),
agentNames: a.agentNames,
agentIDs: a.agentIDs,
asynchronousY: nullableMax(a.asynchronousY, b.asynchronousY),
};
}
@ -42,7 +42,7 @@ define([
renderPre(stage, env) {
const baseResults = {
topShift: 0,
agentNames: [],
agentIDs: [],
asynchronousY: null,
};

View File

@ -171,7 +171,7 @@ define([
top: 3,
bottom: 2,
},
mode: {
tag: {
padding: {
top: 1,
left: 3,

View File

@ -180,7 +180,7 @@ define([
top: 3,
bottom: 4,
},
mode: {
tag: {
padding: {
top: 2,
left: 5,

View File

@ -178,7 +178,7 @@ define([
top: 3,
bottom: 2,
},
mode: {
tag: {
padding: {
top: 2,
left: 4,

View File

@ -164,7 +164,7 @@ define([
top: 3,
bottom: 2,
},
mode: {
tag: {
padding: {
top: 2,
left: 3,
@ -376,8 +376,8 @@ define([
this.blocks.ref.boxRenderer = this.renderRefBlock.bind(this);
this.blocks[''].boxRenderer = this.renderBlock.bind(this);
this.blocks.ref.section.mode.boxRenderer = this.renderTag;
this.blocks[''].section.mode.boxRenderer = this.renderTag;
this.blocks.ref.section.tag.boxRenderer = this.renderTag;
this.blocks[''].section.tag.boxRenderer = this.renderTag;
this.blocks[''].sepRenderer = this.renderSeparator.bind(this);
}

View File

@ -9,6 +9,7 @@ define([
'sequence/SequenceDiagram_spec',
'sequence/Tokeniser_spec',
'sequence/Parser_spec',
'sequence/MarkdownParser_spec',
'sequence/LabelPatternParser_spec',
'sequence/Generator_spec',
'sequence/Renderer_spec',

View File

@ -14,12 +14,14 @@ define(['svg/SVGUtilities'], (svg) => {
}
}
const EMPTY = [];
class SVGTextBlock {
constructor(container, initialState = {}) {
this.container = container;
this.state = {
attrs: {},
text: '',
formatted: EMPTY,
x: 0,
y: 0,
};
@ -57,18 +59,19 @@ define(['svg/SVGUtilities'], (svg) => {
}
_renderText() {
if(!this.state.text) {
if(!this.state.formatted) {
this._reset();
return;
}
const lines = this.state.text.split('\n');
this._rebuildNodes(lines.length);
const formatted = this.state.formatted;
this._rebuildNodes(formatted.length);
let maxWidth = 0;
this.nodes.forEach(({text, element}, i) => {
text.nodeValue = lines[i];
maxWidth = Math.max(maxWidth, lines[i].length);
const ln = formatted[i].reduce((v, pt) => v + pt.text, '');
text.nodeValue = ln;
maxWidth = Math.max(maxWidth, ln.length);
});
this.width = maxWidth;
}
@ -100,12 +103,12 @@ define(['svg/SVGUtilities'], (svg) => {
if(this.state.attrs !== oldState.attrs) {
this._reset();
oldState.text = '';
oldState.formatted = EMPTY;
}
const oldNodes = this.nodes.length;
if(this.state.text !== oldState.text) {
if(this.state.formatted !== oldState.formatted) {
this._renderText();
}
@ -120,29 +123,29 @@ define(['svg/SVGUtilities'], (svg) => {
}
class SizeTester {
measure(attrs, content) {
if(!content) {
measure(attrs, formatted) {
if(!formatted || !formatted.length) {
return {width: 0, height: 0};
}
const lines = content.split('\n');
let width = 0;
lines.forEach((line) => {
width = Math.max(width, line.length);
formatted.forEach((line) => {
const length = line.reduce((v, pt) => v + pt.text.length, 0);
width = Math.max(width, length);
});
return {
width,
height: lines.length,
height: formatted.length,
};
}
measureHeight(attrs, content) {
if(!content) {
measureHeight(attrs, formatted) {
if(!formatted) {
return 0;
}
return content.split('\n').length;
return formatted.length;
}
resetCache() {

View File

@ -46,24 +46,10 @@ define([
return g;
}
function renderBoxedText(text, {
x,
y,
padding,
boxAttrs,
labelAttrs,
boxLayer,
labelLayer,
boxRenderer = null,
SVGTextBlockClass = SVGTextBlock,
}) {
if(!text) {
return {width: 0, height: 0, label: null, box: null};
}
function calculateAnchor(x, attrs, padding) {
let shift = 0;
let anchorX = x;
switch(labelAttrs['text-anchor']) {
switch(attrs['text-anchor']) {
case 'middle':
shift = 0.5;
anchorX += (padding.left - padding.right) / 2;
@ -77,10 +63,29 @@ define([
anchorX += padding.left;
break;
}
return {shift, anchorX};
}
function renderBoxedText(formatted, {
x,
y,
padding,
boxAttrs,
labelAttrs,
boxLayer,
labelLayer,
boxRenderer = null,
SVGTextBlockClass = SVGTextBlock,
}) {
if(!formatted || !formatted.length) {
return {width: 0, height: 0, label: null, box: null};
}
const {shift, anchorX} = calculateAnchor(x, labelAttrs, padding);
const label = new SVGTextBlockClass(labelLayer, {
attrs: labelAttrs,
text,
formatted,
x: anchorX,
y: y + padding.top,
});

View File

@ -56,7 +56,7 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
describe('renderBoxedText', () => {
it('renders a label', () => {
const o = document.createElement('p');
const rendered = SVGShapes.renderBoxedText('foo', {
const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], {
x: 1,
y: 2,
padding: {left: 4, top: 8, right: 16, bottom: 32},
@ -65,7 +65,7 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
boxLayer: o,
labelLayer: o,
});
expect(rendered.label.state.text).toEqual('foo');
expect(rendered.label.state.formatted).toEqual([[{text: 'foo'}]]);
expect(rendered.label.state.x).toEqual(5);
expect(rendered.label.state.y).toEqual(10);
expect(rendered.label.firstLine().parentNode).toEqual(o);
@ -73,7 +73,7 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
it('positions a box beneath the rendered label', () => {
const o = document.createElement('p');
const rendered = SVGShapes.renderBoxedText('foo', {
const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], {
x: 1,
y: 2,
padding: {left: 4, top: 8, right: 16, bottom: 32},
@ -91,7 +91,7 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => {
it('returns the size of the rendered box', () => {
const o = document.createElement('p');
const rendered = SVGShapes.renderBoxedText('foo', {
const rendered = SVGShapes.renderBoxedText([[{text: 'foo'}]], {
x: 1,
y: 2,
padding: {left: 4, top: 8, right: 16, bottom: 32},

View File

@ -23,84 +23,107 @@ define(['./SVGUtilities'], (svg) => {
}
}
function populateSvgTextLine(node, formattedLine) {
if(!Array.isArray(formattedLine)) {
throw new Error('Invalid formatted text line: ' + formattedLine);
}
formattedLine.forEach(({text, attrs}) => {
const textNode = svg.makeText(text);
if(attrs) {
const span = svg.make('tspan', attrs);
span.appendChild(textNode);
node.appendChild(span);
} else {
node.appendChild(textNode);
}
});
}
const EMPTY = [];
class SVGTextBlock {
constructor(container, initialState = {}) {
this.container = container;
this.state = {
attrs: {},
text: '',
formatted: EMPTY,
x: 0,
y: 0,
};
this.width = 0;
this.height = 0;
this.nodes = [];
this.lines = [];
this.set(initialState);
}
_rebuildNodes(count) {
if(count > this.nodes.length) {
_rebuildLines(count) {
if(count > this.lines.length) {
const attrs = Object.assign({
'x': this.state.x,
}, this.state.attrs);
while(this.nodes.length < count) {
const element = svg.make('text', attrs);
const text = svg.makeText();
element.appendChild(text);
this.container.appendChild(element);
this.nodes.push({element, text});
while(this.lines.length < count) {
const node = svg.make('text', attrs);
this.container.appendChild(node);
this.lines.push({node, latest: ''});
}
} else {
while(this.nodes.length > count) {
const {element} = this.nodes.pop();
this.container.removeChild(element);
while(this.lines.length > count) {
const {node} = this.lines.pop();
this.container.removeChild(node);
}
}
}
_reset() {
this._rebuildNodes(0);
this._rebuildLines(0);
this.width = 0;
this.height = 0;
}
_renderText() {
if(!this.state.text) {
const {formatted} = this.state;
if(!formatted || !formatted.length) {
this._reset();
return;
}
if(!Array.isArray(formatted)) {
throw new Error('Invalid formatted text: ' + formatted);
}
const lines = this.state.text.split('\n');
this._rebuildNodes(lines.length);
this._rebuildLines(formatted.length);
let maxWidth = 0;
this.nodes.forEach(({text, element}, i) => {
if(text.nodeValue !== lines[i]) {
text.nodeValue = lines[i];
this.lines.forEach((ln, i) => {
const id = JSON.stringify(formatted[i]);
if(id !== ln.latest) {
svg.empty(ln.node);
populateSvgTextLine(ln.node, formatted[i]);
ln.latest = id;
}
maxWidth = Math.max(maxWidth, element.getComputedTextLength());
maxWidth = Math.max(maxWidth, ln.node.getComputedTextLength());
});
this.width = maxWidth;
}
_updateX() {
this.nodes.forEach(({element}) => {
element.setAttribute('x', this.state.x);
this.lines.forEach(({node}) => {
node.setAttribute('x', this.state.x);
});
}
_updateY() {
const {size, lineHeight} = fontDetails(this.state.attrs);
this.nodes.forEach(({element}, i) => {
element.setAttribute('y', this.state.y + i * lineHeight + size);
this.lines.forEach(({node}, i) => {
node.setAttribute('y', this.state.y + i * lineHeight + size);
});
this.height = lineHeight * this.nodes.length;
this.height = lineHeight * this.lines.length;
}
firstLine() {
if(this.nodes.length > 0) {
return this.nodes[0].element;
if(this.lines.length > 0) {
return this.lines[0].node;
} else {
return null;
}
@ -112,12 +135,12 @@ define(['./SVGUtilities'], (svg) => {
if(this.state.attrs !== oldState.attrs) {
this._reset();
oldState.text = '';
oldState.formatted = EMPTY;
}
const oldNodes = this.nodes.length;
const oldLines = this.lines.length;
if(this.state.text !== oldState.text) {
if(this.state.formatted !== oldState.formatted) {
this._renderText();
}
@ -125,7 +148,7 @@ define(['./SVGUtilities'], (svg) => {
this._updateX();
}
if(this.state.y !== oldState.y || this.nodes.length !== oldNodes) {
if(this.state.y !== oldState.y || this.lines.length !== oldLines) {
this._updateY();
}
}
@ -142,18 +165,18 @@ define(['./SVGUtilities'], (svg) => {
this.cache = new Map();
}
measure(attrs, content) {
if(!content) {
measure(attrs, formatted) {
if(!formatted || !formatted.length) {
return {width: 0, height: 0};
}
if(!Array.isArray(formatted)) {
throw new Error('Invalid formatted text: ' + formatted);
}
let tester = this.cache.get(attrs);
if(!tester) {
const text = svg.makeText();
const node = svg.make('text', attrs);
node.appendChild(text);
this.testers.appendChild(node);
tester = {text, node};
tester = svg.make('text', attrs);
this.testers.appendChild(tester);
this.cache.set(attrs, tester);
}
@ -161,26 +184,28 @@ define(['./SVGUtilities'], (svg) => {
this.container.appendChild(this.testers);
}
const lines = content.split('\n');
let width = 0;
lines.forEach((line) => {
tester.text.nodeValue = line;
width = Math.max(width, tester.node.getComputedTextLength());
formatted.forEach((line) => {
svg.empty(tester);
populateSvgTextLine(tester, line);
width = Math.max(width, tester.getComputedTextLength());
});
return {
width,
height: lines.length * fontDetails(attrs).lineHeight,
height: formatted.length * fontDetails(attrs).lineHeight,
};
}
measureHeight(attrs, content) {
if(!content) {
measureHeight(attrs, formatted) {
if(!formatted) {
return 0;
}
if(!Array.isArray(formatted)) {
throw new Error('Invalid formatted text: ' + formatted);
}
const lines = content.split('\n');
return lines.length * fontDetails(attrs).lineHeight;
return formatted.length * fontDetails(attrs).lineHeight;
}
resetCache() {

View File

@ -23,7 +23,7 @@ defineDescribe('SVGTextBlock', [
describe('constructor', () => {
it('defaults to blank text at 0, 0', () => {
expect(block.state.text).toEqual('');
expect(block.state.formatted).toEqual([]);
expect(block.state.x).toEqual(0);
expect(block.state.y).toEqual(0);
expect(hold.children.length).toEqual(0);
@ -31,15 +31,18 @@ defineDescribe('SVGTextBlock', [
it('does not explode if given no setup', () => {
block = new SVGTextBlock(hold);
expect(block.state.text).toEqual('');
expect(block.state.formatted).toEqual([]);
expect(block.state.x).toEqual(0);
expect(block.state.y).toEqual(0);
expect(hold.children.length).toEqual(0);
});
it('adds the given text if specified', () => {
block = new SVGTextBlock(hold, {attrs, text: 'abc'});
expect(block.state.text).toEqual('abc');
it('adds the given formatted text if specified', () => {
block = new SVGTextBlock(hold, {
attrs,
formatted: [[{text: 'abc'}]],
});
expect(block.state.formatted).toEqual([[{text: 'abc'}]]);
expect(hold.children.length).toEqual(1);
});
@ -52,31 +55,35 @@ defineDescribe('SVGTextBlock', [
describe('.set', () => {
it('sets the text to the given content', () => {
block.set({text: 'foo'});
expect(block.state.text).toEqual('foo');
block.set({formatted: [[{text: 'foo'}]]});
expect(block.state.formatted).toEqual([[{text: 'foo'}]]);
expect(hold.children.length).toEqual(1);
expect(hold.children[0].innerHTML).toEqual('foo');
});
it('renders multiline text', () => {
block.set({text: 'foo\nbar'});
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
expect(hold.children.length).toEqual(2);
expect(hold.children[0].innerHTML).toEqual('foo');
expect(hold.children[1].innerHTML).toEqual('bar');
});
it('populates width and height with the size of the text', () => {
block.set({text: 'foo\nbar'});
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
expect(block.width).toBeGreaterThan(0);
expect(block.height).toEqual(30);
});
it('re-uses text nodes when possible, adding more if needed', () => {
block.set({text: 'foo\nbar'});
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
const line0 = hold.children[0];
const line1 = hold.children[1];
block.set({text: 'zig\nzag\nbaz'});
block.set({formatted: [
[{text: 'zig'}],
[{text: 'zag'}],
[{text: 'baz'}],
]});
expect(hold.children.length).toEqual(3);
expect(hold.children[0]).toEqual(line0);
@ -87,10 +94,10 @@ defineDescribe('SVGTextBlock', [
});
it('re-uses text nodes when possible, removing extra if needed', () => {
block.set({text: 'foo\nbar'});
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
const line0 = hold.children[0];
block.set({text: 'zig'});
block.set({formatted: [[{text: 'zig'}]]});
expect(hold.children.length).toEqual(1);
expect(hold.children[0]).toEqual(line0);
@ -98,7 +105,7 @@ defineDescribe('SVGTextBlock', [
});
it('positions text nodes and applies attributes', () => {
block.set({text: 'foo\nbar'});
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
expect(hold.children.length).toEqual(2);
expect(hold.children[0].getAttribute('x')).toEqual('0');
expect(hold.children[0].getAttribute('y')).toEqual('10');
@ -109,7 +116,7 @@ defineDescribe('SVGTextBlock', [
});
it('moves all nodes', () => {
block.set({text: 'foo\nbaz'});
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
block.set({x: 5, y: 7});
expect(hold.children[0].getAttribute('x')).toEqual('5');
expect(hold.children[0].getAttribute('y')).toEqual('17');
@ -118,10 +125,10 @@ defineDescribe('SVGTextBlock', [
});
it('clears if the text is empty', () => {
block.set({text: 'foo\nbaz'});
block.set({text: ''});
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
block.set({formatted: []});
expect(hold.children.length).toEqual(0);
expect(block.state.text).toEqual('');
expect(block.state.formatted).toEqual([]);
expect(block.width).toEqual(0);
expect(block.height).toEqual(0);
});
@ -135,28 +142,39 @@ defineDescribe('SVGTextBlock', [
});
describe('.measure', () => {
it('calculates the size of the rendered text', () => {
const size = tester.measure(attrs, 'foo');
it('calculates the size of the formatted text', () => {
const size = tester.measure(attrs, [[{text: 'foo'}]]);
expect(size.width).toBeGreaterThan(0);
expect(size.height).toEqual(15);
});
it('measures multiline text', () => {
const size = tester.measure(attrs, 'foo\nbar');
const size = tester.measure(attrs, [
[{text: 'foo'}],
[{text: 'bar'}],
]);
expect(size.width).toBeGreaterThan(0);
expect(size.height).toEqual(30);
});
it('returns 0, 0 for empty content', () => {
const size = tester.measure(attrs, '');
const size = tester.measure(attrs, []);
expect(size.width).toEqual(0);
expect(size.height).toEqual(0);
});
it('returns the maximum width for multiline text', () => {
const size0 = tester.measure(attrs, 'foo');
const size1 = tester.measure(attrs, 'longline');
const size = tester.measure(attrs, 'foo\nlongline\nfoo');
const size0 = tester.measure(attrs, [
[{text: 'foo'}],
]);
const size1 = tester.measure(attrs, [
[{text: 'longline'}],
]);
const size = tester.measure(attrs, [
[{text: 'foo'}],
[{text: 'longline'}],
[{text: 'foo'}],
]);
expect(size1.width).toBeGreaterThan(size0.width);
expect(size.width).toEqual(size1.width);
});
@ -164,39 +182,44 @@ defineDescribe('SVGTextBlock', [
describe('.measureHeight', () => {
it('calculates the height of the rendered text', () => {
const height = tester.measureHeight(attrs, 'foo');
const height = tester.measureHeight(attrs, [
[{text: 'foo'}],
]);
expect(height).toEqual(15);
});
it('measures multiline text', () => {
const height = tester.measureHeight(attrs, 'foo\nbar');
const height = tester.measureHeight(attrs, [
[{text: 'foo'}],
[{text: 'bar'}],
]);
expect(height).toEqual(30);
});
it('returns 0 for empty content', () => {
const height = tester.measureHeight(attrs, '');
const height = tester.measureHeight(attrs, []);
expect(height).toEqual(0);
});
it('does not require the container', () => {
tester.measureHeight(attrs, 'foo');
tester.measureHeight(attrs, [[{text: 'foo'}]]);
expect(hold.children.length).toEqual(0);
});
});
describe('.detach', () => {
it('removes the test node from the DOM', () => {
tester.measure(attrs, 'foo');
tester.measure(attrs, [[{text: 'foo'}]]);
expect(hold.children.length).toEqual(1);
tester.detach();
expect(hold.children.length).toEqual(0);
});
it('does not prevent using the tester again later', () => {
tester.measure(attrs, 'foo');
tester.measure(attrs, [[{text: 'foo'}]]);
tester.detach();
const size = tester.measure(attrs, 'foo');
const size = tester.measure(attrs, [[{text: 'foo'}]]);
expect(hold.children.length).toEqual(1);
expect(size.width).toBeGreaterThan(0);
});