Beginnings of theme switching capability

This commit is contained in:
David Evans 2017-11-05 22:39:36 +00:00
parent 5b6cbd518e
commit f6f557bcd7
16 changed files with 398 additions and 47 deletions

View File

@ -11,13 +11,15 @@
'sequence/Generator', 'sequence/Generator',
'sequence/Renderer', 'sequence/Renderer',
'sequence/themes/Basic', 'sequence/themes/Basic',
'sequence/themes/Chunky',
], ( ], (
Interface, Interface,
Exporter, Exporter,
Parser, Parser,
Generator, Generator,
Renderer, Renderer,
Theme BasicTheme,
ChunkyTheme
) => { ) => {
const defaultCode = ( const defaultCode = (
'title Labyrinth\n' + 'title Labyrinth\n' +
@ -41,7 +43,10 @@
defaultCode, defaultCode,
parser: new Parser(), parser: new Parser(),
generator: new Generator(), generator: new Generator(),
renderer: new Renderer(new Theme()), renderer: new Renderer({themes: [
new BasicTheme(),
new ChunkyTheme(),
]}),
exporter: new Exporter(), exporter: new Exporter(),
localStorage: 'src', localStorage: 'src',
}); });

View File

@ -17,8 +17,18 @@
return o; return o;
} }
const FAVICON_SRC = (
'theme chunky\n' +
'define ABC as A, DEF as B\n' +
'A -> B\n' +
'B -> ]\n' +
'] -> B\n' +
'B -> A\n' +
'terminators fade'
);
const SAMPLE_REGEX = new RegExp( const SAMPLE_REGEX = new RegExp(
/<img src="screenshots\/([^"]*)"[^>]*>[\s]*```(?!shell).*\n([^]+?)```/g /<img src="([^"]*)"[^>]*>[\s]*```(?!shell).*\n([^]+?)```/g
); );
function findSamples(content) { function findSamples(content) {
@ -34,9 +44,23 @@
code: match[2], code: match[2],
}); });
} }
results.push({
file: 'favicon.png',
code: FAVICON_SRC,
height: 64,
});
return results; return results;
} }
function filename(path) {
const p = path.lastIndexOf('/');
if(p !== -1) {
return path.substr(p + 1);
} else {
return path;
}
}
const PNG_RESOLUTION = 4; const PNG_RESOLUTION = 4;
/* jshint -W072 */ // Allow several required modules /* jshint -W072 */ // Allow several required modules
@ -45,25 +69,30 @@
'sequence/Generator', 'sequence/Generator',
'sequence/Renderer', 'sequence/Renderer',
'sequence/themes/Basic', 'sequence/themes/Basic',
'sequence/themes/Chunky',
'interface/Exporter', 'interface/Exporter',
], ( ], (
Parser, Parser,
Generator, Generator,
Renderer, Renderer,
Theme, BasicTheme,
ChunkyTheme,
Exporter Exporter
) => { ) => {
const parser = new Parser(); const parser = new Parser();
const generator = new Generator(); const generator = new Generator();
const theme = new Theme(); const themes = [
new BasicTheme(),
new ChunkyTheme(),
];
const status = makeNode('div', {'class': 'status'}); const status = makeNode('div', {'class': 'status'});
const statusText = makeText('Loading\u2026'); const statusText = makeText('Loading\u2026');
status.appendChild(statusText); status.appendChild(statusText);
document.body.appendChild(status); document.body.appendChild(status);
function renderSample({file, code}) { function renderSample({file, code, height}) {
const renderer = new Renderer(theme); const renderer = new Renderer({themes});
const exporter = new Exporter(); const exporter = new Exporter();
const hold = makeNode('div', {'class': 'hold'}); const hold = makeNode('div', {'class': 'hold'});
@ -78,12 +107,15 @@
hold.appendChild(raster); hold.appendChild(raster);
hold.appendChild(makeNode('img', { hold.appendChild(makeNode('img', {
'src': 'screenshots/' + file, 'src': file,
'class': 'original', 'class': 'original',
'title': 'original', 'title': 'original',
})); }));
const downloadPNG = makeNode('a', {'href': '#', 'download': file}); const downloadPNG = makeNode('a', {
'href': '#',
'download': filename(file),
});
downloadPNG.appendChild(makeText('Download PNG')); downloadPNG.appendChild(makeText('Download PNG'));
hold.appendChild(downloadPNG); hold.appendChild(downloadPNG);
@ -92,7 +124,11 @@
const parsed = parser.parse(code); const parsed = parser.parse(code);
const sequence = generator.generate(parsed); const sequence = generator.generate(parsed);
renderer.render(sequence); renderer.render(sequence);
exporter.getPNGURL(renderer, PNG_RESOLUTION, (url) => { let resolution = PNG_RESOLUTION;
if(height) {
resolution = height / renderer.height;
}
exporter.getPNGURL(renderer, resolution, (url) => {
raster.setAttribute('src', url); raster.setAttribute('src', url);
downloadPNG.setAttribute('href', url); downloadPNG.setAttribute('href', url);
}); });

View File

@ -121,6 +121,9 @@ define(['core/ArrayUtilities'], (array) => {
'title': {type: 'keyword', suggest: true, then: { 'title': {type: 'keyword', suggest: true, then: {
'': textToEnd, '': textToEnd,
}}, }},
'theme': {type: 'keyword', suggest: true, then: {
'': textToEnd,
}},
'terminators': {type: 'keyword', suggest: true, then: { 'terminators': {type: 'keyword', suggest: true, then: {
'none': {type: 'keyword', suggest: true, then: {}}, 'none': {type: 'keyword', suggest: true, then: {}},
'cross': {type: 'keyword', suggest: true, then: {}}, 'cross': {type: 'keyword', suggest: true, then: {}},

View File

@ -539,6 +539,7 @@ define(['core/ArrayUtilities'], (array) => {
return { return {
meta: { meta: {
title: meta.title, title: meta.title,
theme: meta.theme,
}, },
agents: this.agents.slice(), agents: this.agents.slice(),
stages: globals.stages, stages: globals.stages,

View File

@ -148,13 +148,13 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
}; };
describe('.generate', () => { describe('.generate', () => {
it('propagates title metadata', () => { it('propagates title and theme metadata', () => {
const input = { const input = {
meta: {title: 'bar'}, meta: {title: 'bar', theme: 'zig', nope: 'skip'},
stages: [], stages: [],
}; };
const sequence = generator.generate(input); const sequence = generator.generate(input);
expect(sequence.meta).toEqual({title: 'bar'}); expect(sequence.meta).toEqual({title: 'bar', theme: 'zig'});
}); });
it('returns an empty sequence for blank input', () => { it('returns an empty sequence for blank input', () => {

View File

@ -188,6 +188,15 @@ define([
return true; return true;
}, },
(line, meta) => { // theme
if(tokenKeyword(line[0]) !== 'theme') {
return null;
}
meta.theme = joinLabel(line, 1);
return true;
},
(line, meta) => { // terminators (line, meta) => { // terminators
if(tokenKeyword(line[0]) !== 'terminators') { if(tokenKeyword(line[0]) !== 'terminators') {
return null; return null;
@ -356,6 +365,7 @@ define([
const result = { const result = {
meta: { meta: {
title: '', title: '',
theme: '',
terminators: 'none', terminators: 'none',
}, },
stages: [], stages: [],

View File

@ -33,6 +33,7 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
expect(parsed).toEqual({ expect(parsed).toEqual({
meta: { meta: {
title: '', title: '',
theme: '',
terminators: 'none', terminators: 'none',
}, },
stages: [], stages: [],
@ -44,6 +45,11 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
expect(parsed.meta.title).toEqual('foo'); expect(parsed.meta.title).toEqual('foo');
}); });
it('reads theme metadata', () => {
const parsed = parser.parse('theme foo');
expect(parsed.meta.theme).toEqual('foo');
});
it('reads terminators metadata', () => { it('reads terminators metadata', () => {
const parsed = parser.parse('terminators bar'); const parsed = parser.parse('terminators bar');
expect(parsed.meta.terminators).toEqual('bar'); expect(parsed.meta.terminators).toEqual('bar');

View File

@ -63,8 +63,24 @@ define([
}; };
} }
function makeThemes(themes) {
if(themes.length === 0) {
throw new Error('Cannot render without a theme');
}
const themeMap = new Map();
themes.forEach((theme) => {
themeMap.set(theme.name, theme);
});
themeMap.set('', themes[0]);
return themeMap;
}
let globalNamespace = 0;
return class Renderer { return class Renderer {
constructor(theme, { constructor({
themes = [],
namespace = null,
components = null, components = null,
SVGTextBlockClass = SVGShapes.TextBlock, SVGTextBlockClass = SVGShapes.TextBlock,
} = {}) { } = {}) {
@ -93,11 +109,16 @@ define([
this.state = {}; this.state = {};
this.width = 0; this.width = 0;
this.height = 0; this.height = 0;
this.theme = theme; this.themes = makeThemes(themes);
this.theme = null;
this.namespace = namespace;
if(namespace === null) {
this.namespace = 'R' + globalNamespace;
++ globalNamespace;
}
this.components = components; this.components = components;
this.SVGTextBlockClass = SVGTextBlockClass; this.SVGTextBlockClass = SVGTextBlockClass;
this.knownDefs = new Set(); this.knownDefs = new Set();
this.currentSequence = null;
this.buildStaticElements(); this.buildStaticElements();
this.components.forEach((component) => { this.components.forEach((component) => {
component.makeState(this.state); component.makeState(this.state);
@ -112,11 +133,13 @@ define([
this.defs = svg.make('defs'); this.defs = svg.make('defs');
this.mask = svg.make('mask', { this.mask = svg.make('mask', {
'id': 'lineMask', 'id': this.namespace + 'LineMask',
'maskUnits': 'userSpaceOnUse', 'maskUnits': 'userSpaceOnUse',
}); });
this.maskReveal = svg.make('rect', {'fill': '#FFFFFF'}); this.maskReveal = svg.make('rect', {'fill': '#FFFFFF'});
this.agentLines = svg.make('g', {'mask': 'url(#lineMask)'}); this.agentLines = svg.make('g', {
'mask': 'url(#' + this.namespace + 'LineMask)',
});
this.blocks = svg.make('g'); this.blocks = svg.make('g');
this.sections = svg.make('g'); this.sections = svg.make('g');
this.actionShapes = svg.make('g'); this.actionShapes = svg.make('g');
@ -133,11 +156,15 @@ define([
} }
addDef(name, generator) { addDef(name, generator) {
const namespacedName = this.namespace + name;
if(this.knownDefs.has(name)) { if(this.knownDefs.has(name)) {
return; return namespacedName;
} }
this.knownDefs.add(name); this.knownDefs.add(name);
this.defs.appendChild(generator()); const def = generator();
def.setAttribute('id', namespacedName);
this.defs.appendChild(def);
return namespacedName;
} }
addSeparation(agentName1, agentName2, dist) { addSeparation(agentName1, agentName2, dist) {
@ -517,16 +544,6 @@ define([
this.height = (y1 - y0); this.height = (y1 - y0);
} }
setTheme(theme) {
if(this.theme === theme) {
return;
}
this.theme = theme;
if(this.currentSequence) {
this.render(this.currentSequence);
}
}
_reset() { _reset() {
this.knownDefs.clear(); this.knownDefs.clear();
svg.empty(this.defs); svg.empty(this.defs);
@ -546,6 +563,12 @@ define([
render(sequence) { render(sequence) {
this._reset(); this._reset();
const themeName = sequence.meta.theme;
this.theme = this.themes.get(themeName);
if(!this.theme) {
this.theme = this.themes.get('');
}
this.title.set({ this.title.set({
attrs: this.theme.titleAttrs, attrs: this.theme.titleAttrs,
text: sequence.meta.title, text: sequence.meta.title,
@ -564,7 +587,6 @@ define([
this.sizer.resetCache(); this.sizer.resetCache();
this.sizer.detach(); this.sizer.detach();
this.currentSequence = sequence;
} }
getAgentX(name) { getAgentX(name) {

View File

@ -3,14 +3,14 @@ defineDescribe('Sequence Renderer', [
'./themes/Basic', './themes/Basic',
], ( ], (
Renderer, Renderer,
Theme BasicTheme
) => { ) => {
'use strict'; 'use strict';
let renderer = null; let renderer = null;
beforeEach(() => { beforeEach(() => {
renderer = new Renderer(new Theme()); renderer = new Renderer({themes: [new BasicTheme()]});
document.body.appendChild(renderer.svg()); document.body.appendChild(renderer.svg());
}); });

View File

@ -155,11 +155,8 @@ define([
render(y, {x, label}, env, isBegin) { render(y, {x, label}, env, isBegin) {
const config = env.theme.agentCap.fade; const config = env.theme.agentCap.fade;
const gradID = isBegin ? 'fadeIn' : 'fadeOut'; const gradID = env.addDef(isBegin ? 'FadeIn' : 'FadeOut', () => {
env.addDef(gradID, () => {
const grad = svg.make('linearGradient', { const grad = svg.make('linearGradient', {
'id': gradID,
'x1': '0%', 'x1': '0%',
'y1': isBegin ? '100%' : '0%', 'y1': isBegin ? '100%' : '0%',
'x2': '0%', 'x2': '0%',

View File

@ -9,7 +9,7 @@ defineDescribe('Sequence Integration', [
Parser, Parser,
Generator, Generator,
Renderer, Renderer,
Theme, BasicTheme,
SVGTextBlock SVGTextBlock
) => { ) => {
'use strict'; 'use strict';
@ -20,10 +20,14 @@ defineDescribe('Sequence Integration', [
let theme = null; let theme = null;
beforeEach(() => { beforeEach(() => {
theme = new Theme(); theme = new BasicTheme();
parser = new Parser(); parser = new Parser();
generator = new Generator(); generator = new Generator();
renderer = new Renderer(theme, {SVGTextBlockClass: SVGTextBlock}); renderer = new Renderer({
themes: [new BasicTheme()],
namespace: '',
SVGTextBlockClass: SVGTextBlock,
});
document.body.appendChild(renderer.svg()); document.body.appendChild(renderer.svg());
}); });
@ -45,12 +49,12 @@ defineDescribe('Sequence Integration', [
expect(getSimplifiedContent(renderer)).toEqual( expect(getSimplifiedContent(renderer)).toEqual(
'<svg width="100%" height="100%" viewBox="-5 -5 10 10">' + '<svg width="100%" height="100%" viewBox="-5 -5 10 10">' +
'<defs>' + '<defs>' +
'<mask id="lineMask" maskUnits="userSpaceOnUse">' + '<mask id="LineMask" maskUnits="userSpaceOnUse">' +
'<rect fill="#FFFFFF" x="-5" y="-5" width="10" height="10">' + '<rect fill="#FFFFFF" x="-5" y="-5" width="10" height="10">' +
'</rect>' + '</rect>' +
'</mask>' + '</mask>' +
'</defs>' + '</defs>' +
'<g mask="url(#lineMask)"></g>' + '<g mask="url(#LineMask)"></g>' +
'</svg>' '</svg>'
); );
}); });
@ -63,12 +67,12 @@ defineDescribe('Sequence Integration', [
expect(getSimplifiedContent(renderer)).toEqual( expect(getSimplifiedContent(renderer)).toEqual(
'<svg width="100%" height="100%" viewBox="-11.5 -16 23 21">' + '<svg width="100%" height="100%" viewBox="-11.5 -16 23 21">' +
'<defs>' + '<defs>' +
'<mask id="lineMask" maskUnits="userSpaceOnUse">' + '<mask id="LineMask" maskUnits="userSpaceOnUse">' +
'<rect fill="#FFFFFF" x="-11.5" y="-16" width="23" height="21">' + '<rect fill="#FFFFFF" x="-11.5" y="-16" width="23" height="21">' +
'</rect>' + '</rect>' +
'</mask>' + '</mask>' +
'</defs>' + '</defs>' +
'<g mask="url(#lineMask)"></g>' + '<g mask="url(#LineMask)"></g>' +
'<text' + '<text' +
' x="0"' + ' x="0"' +
' font-family="sans-serif"' + ' font-family="sans-serif"' +

View File

@ -235,8 +235,9 @@ define(['core/ArrayUtilities', 'svg/SVGShapes'], (array, SVGShapes) => {
}, },
}; };
return class Theme { return class BasicTheme {
constructor() { constructor() {
this.name = 'basic';
Object.assign(this, SETTINGS); Object.assign(this, SETTINGS);
} }
}; };

View File

@ -1,8 +1,13 @@
defineDescribe('Basic Theme', ['./Basic'], (Theme) => { defineDescribe('Basic Theme', ['./Basic'], (BasicTheme) => {
'use strict'; 'use strict';
const theme = new BasicTheme();
it('has a name', () => {
expect(theme.name).toEqual('basic');
});
it('contains settings for the theme', () => { it('contains settings for the theme', () => {
const theme = new Theme();
expect(theme.outerMargin).toEqual(5); expect(theme.outerMargin).toEqual(5);
}); });
}); });

View File

@ -0,0 +1,247 @@
define(['core/ArrayUtilities', 'svg/SVGShapes'], (array, SVGShapes) => {
'use strict';
const LINE_HEIGHT = 1.3;
const SETTINGS = {
titleMargin: 10,
outerMargin: 5,
agentMargin: 8,
actionMargin: 5,
agentLineHighlightRadius: 4,
agentCap: {
box: {
padding: {
top: 1,
left: 3,
right: 3,
bottom: 1,
},
arrowBottom: 2 + 14 * 1.3 / 2,
boxAttrs: {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 3,
'rx': 4,
'ry': 4,
},
labelAttrs: {
'font-family': 'sans-serif',
'font-weight': 'bold',
'font-size': 14,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
},
cross: {
size: 20,
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
},
},
bar: {
attrs: {
'fill': '#000000',
'stroke': '#000000',
'stroke-width': 3,
'height': 4,
},
},
fade: {
width: 5,
height: 10,
},
none: {
height: 10,
},
},
connect: {
loopbackRadius: 6,
lineAttrs: {
'solid': {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
},
'dash': {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-dasharray': '4, 2',
},
},
arrow: {
width: 10,
height: 12,
attrs: {
'fill': '#000000',
'stroke-width': 0,
'stroke-linejoin': 'miter',
},
},
label: {
padding: 6,
margin: {top: 2, bottom: 1},
attrs: {
'font-family': 'sans-serif',
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
},
loopbackAttrs: {
'font-family': 'sans-serif',
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
mask: {
padding: {
top: 0,
left: 3,
right: 3,
bottom: 1,
},
},
},
block: {
margin: {
top: 0,
bottom: 0,
},
boxAttrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1.5,
'rx': 2,
'ry': 2,
},
section: {
padding: {
top: 3,
bottom: 2,
},
mode: {
padding: {
top: 1,
left: 3,
right: 3,
bottom: 0,
},
boxAttrs: {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 3,
'rx': 2,
'ry': 2,
},
labelAttrs: {
'font-family': 'sans-serif',
'font-weight': 'bold',
'font-size': 9,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
label: {
padding: {
top: 1,
left: 5,
right: 3,
bottom: 0,
},
labelAttrs: {
'font-family': 'sans-serif',
'font-size': 8,
'line-height': LINE_HEIGHT,
'text-anchor': 'left',
},
},
},
separator: {
attrs: {
'stroke': '#000000',
'stroke-width': 1.5,
'stroke-dasharray': '4, 2',
},
},
},
note: {
'text': {
margin: {top: 0, left: 2, right: 2, bottom: 0},
padding: {top: 2, left: 2, right: 2, bottom: 2},
overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': '#FFFFFF',
}),
labelAttrs: {
'font-family': 'sans-serif',
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
'note': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 5, left: 5, right: 10, bottom: 5},
overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderNote.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
}, {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
}),
labelAttrs: {
'font-family': 'sans-serif',
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
'state': {
margin: {top: 0, left: 5, right: 5, bottom: 0},
padding: {top: 7, left: 7, right: 7, bottom: 7},
overlap: {left: 10, right: 10},
boxRenderer: SVGShapes.renderBox.bind(null, {
'fill': '#FFFFFF',
'stroke': '#000000',
'stroke-width': 1,
'rx': 10,
'ry': 10,
}),
labelAttrs: {
'font-family': 'sans-serif',
'font-size': 8,
'line-height': LINE_HEIGHT,
},
},
},
titleAttrs: {
'font-family': 'sans-serif',
'font-size': 20,
'line-height': LINE_HEIGHT,
'text-anchor': 'middle',
'class': 'title',
},
agentLineAttrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
},
};
return class ChunkyTheme {
constructor() {
this.name = 'chunky';
Object.assign(this, SETTINGS);
}
};
});

View File

@ -0,0 +1,13 @@
defineDescribe('Chunky Theme', ['./Chunky'], (ChunkyTheme) => {
'use strict';
const theme = new ChunkyTheme();
it('has a name', () => {
expect(theme.name).toEqual('chunky');
});
it('contains settings for the theme', () => {
expect(theme.outerMargin).toEqual(5);
});
});

View File

@ -9,6 +9,7 @@ define([
'sequence/Generator_spec', 'sequence/Generator_spec',
'sequence/Renderer_spec', 'sequence/Renderer_spec',
'sequence/themes/Basic_spec', 'sequence/themes/Basic_spec',
'sequence/themes/Chunky_spec',
'sequence/components/AgentCap_spec', 'sequence/components/AgentCap_spec',
'sequence/components/AgentHighlight_spec', 'sequence/components/AgentHighlight_spec',
'sequence/components/Connect_spec', 'sequence/components/Connect_spec',