smoosic
Version:
<sub>[Github site](https://github.com/Smoosic/smoosic) | [source documentation](https://smoosic.github.io/Smoosic/release/docs/modules.html) | [change notes](https://aarondavidnewman.github.io/Smoosic/changes.html) | [application](https://smoosic.github.i
676 lines (668 loc) • 22.8 kB
text/typescript
import { SvgHelpers, StrokeInfo } from "./svgHelpers";
import { SvgPoint, SvgBox, Renderable, ElementLike, RemoveElementLike } from '../../smo/data/common';
import { layoutDebug } from './layoutDebug';
import { SmoGlobalLayout, SmoPageLayout } from '../../smo/data/scoreModifiers';
import { SmoTextGroup } from '../../smo/data/scoreText';
import { SmoSelection, SmoSelector } from '../../smo/xform/selections';
import { ModifierTab } from '../../smo/xform/selections';
import { VexFlow, Renderer, SVGContext } from '../../common/vex';
const VF = VexFlow;
/**
* classes for managing the SVG containers where the music is rendered. Each
* page is a different SVG element. Screen coordinates need to be mapped to the
* correct page and then to the correct element on that page.
* @module /render/sui/svgPageMap
*/
declare var $: any;
/**
* A selection map maps a sub-section of music (a measure, for instance) to a region
* on the screen. SelectionMap can contain other SelectionMaps with
* different 'T', for instance, notes in a measure, in a 'Russian Dolls' kind of model.
* This allows us to search for elements in < O(n) time and avoid
* expensive geometry operations.
* @category SuiRender
*/
export abstract class SelectionMap<T, K> {
/**
* Create a key from the selection (selector). e.g. (1,1)
* @param selection
*/
abstract createKey(selection: SmoSelection): K;
/**
* get a set of coordinates from this selection, if it has been rendered.
* @param selection
*/
abstract boxFromSelection(selection: SmoSelection): SvgBox;
/**
* Add the selection to our map, and possibly to our child map.
* @param key
* @param selection
*/
abstract addKeyToMap(key: K, selection: SmoSelection): void;
/**
* find a collection of selection that match a bounding box, possibly by
* recursing through our child SelectionMaps.
* @param value
* @param box
* @param rv
*/
abstract findValueInMap(value: T, box: SvgBox): SmoSelection[];
/**
* the outer bounding box of these selections
*/
box: SvgBox = SvgBox.default;
/**
* map of key to child SelectionMaps or SmoSelections
*/
systemMap: Map<K, T> = new Map();
/**
* Given a bounding box (or point), find all the musical elements contained
* in that point
* @param box
* @returns SmoSelection[]
*/
findArtifact(box: SvgBox): SmoSelection[] {
let rv: SmoSelection[] = [];
for (const [key, value] of this.systemMap) {
rv = rv.concat(this.findValueInMap(value, box));
}
return rv;
}
/**
* Add a rendered element to the map, and update the bounding box
* @param selection
* @returns
*/
addArtifact(selection: SmoSelection) {
if (!selection.note || !selection.note.logicalBox) {
return;
}
const bounds = this.boxFromSelection(selection);
if (this.systemMap.size === 0) {
this.box = JSON.parse(JSON.stringify(bounds));
}
const ix = this.createKey(selection);
this.addKeyToMap(ix, selection);
this.box = SvgHelpers.unionRect(bounds, this.box);
}
}
/**
* logic to map a set of notes to a region on the screen, for searching
* @category SuiRender
*/
export class MappedNotes extends SelectionMap<SmoSelection, string>{
createKey(selection: SmoSelection): string {
return `${selection.selector.voice}-${selection.selector.tick}`;
}
boxFromSelection(selection: SmoSelection): SvgBox {
return selection.note?.logicalBox ?? SvgBox.default;
}
addKeyToMap(key: string, selection: SmoSelection) {
this.systemMap.set(key, selection);
}
findValueInMap(value: SmoSelection, box: SvgBox): SmoSelection[] {
const rv: SmoSelection[] = [];
const note = value.note;
if (note && note.logicalBox && SvgHelpers.doesBox1ContainBox2(note.logicalBox, box)) {
rv.push(value);
}
return rv;
}
}
/**
* Map of measures to a region on the page.
* @category SuiRender
*/
export class MappedMeasures extends SelectionMap<MappedNotes, string> {
box: SvgBox = SvgBox.default;
systemMap: Map<string, MappedNotes> = new Map();
createKey(selection: SmoSelection): string {
return `${selection.selector.staff}-${selection.selector.measure}`;
}
boxFromSelection(selection: SmoSelection): SvgBox {
const noteBox = selection.note?.logicalBox ?? SvgBox.default;
return SvgHelpers.unionRect(noteBox, selection.measure.svg.logicalBox);
}
addKeyToMap(key: string, selection: SmoSelection) {
if (!this.systemMap.has(key)) {
const nnote = new MappedNotes();
this.systemMap.set(key, nnote);
}
this.systemMap.get(key)?.addArtifact(selection);
}
findValueInMap(value: MappedNotes, box: SvgBox): SmoSelection[] {
let rv: SmoSelection[] = [];
if (SvgHelpers.doesBox1ContainBox2(value.box, box)) {
rv = rv.concat(value.findArtifact(box));
}
return rv;
}
}
/**
* Map of the systems on a page. Each system has a unique line index
* which is the hash
* @category SuiRender
*/
export class MappedSystems extends SelectionMap<MappedMeasures, number> {
box: SvgBox = SvgBox.default;
systemMap: Map<number, MappedMeasures> = new Map();
createKey(selection: SmoSelection):number {
return selection.measure.svg.lineIndex;
}
boxFromSelection(selection: SmoSelection): SvgBox {
const noteBox = selection.note?.logicalBox ?? SvgBox.default;
return SvgHelpers.unionRect(noteBox, selection.measure.svg.logicalBox);
}
addKeyToMap(selectionKey: number, selection: SmoSelection) {
if (!this.systemMap.has(selectionKey)) {
const nmeasure = new MappedMeasures();
this.systemMap.set(selectionKey, nmeasure);
}
this.systemMap.get(selectionKey)?.addArtifact(selection);
}
findValueInMap(value: MappedMeasures, box: SvgBox) {
let rv: SmoSelection[] = [];
if (SvgHelpers.doesBox1ContainBox2(value.box, box)) {
rv = rv.concat(value.findArtifact(box));
}
return rv;
}
clearMeasure(selection: SmoSelection) {
if (this.systemMap.has(selection.measure.svg.lineIndex)) {
const mmap = this.systemMap.get(selection.measure.svg.lineIndex);
if (mmap) {
this.systemMap.delete(selection.measure.svg.lineIndex);
}
}
}
}
/**
* Each page is a different SVG element, with its own offset within the DOM. This
* makes partial updates faster. SvgPage keeps track of all musical elements in SelectionMaps.
* staff and score modifiers are kept in seperate lists since they may span multiple
* musical elements (e.g. slurs, text elements).
* @category SuiRender
*/
export class SvgPage {
_renderer: Renderer;
pageNumber: number;
box: SvgBox;
systemMap: MappedSystems = new MappedSystems();
modifierYKeys: number[] = [];
modifierTabDivs: Record<number, ModifierTab[]> = {};
static get defaultMap() {
return {
box: SvgBox.default,
systemMap: new Map()
};
}
/**
* Modifiers are divided into `modifierDivs` vertical
* rectangles for event lookup.
*/
static get modifierDivs() {
return 8;
}
/**
* This is the VextFlow renderer context (SVGContext)
* @returns
*/
getContext(): SVGContext {
return this._renderer.getContext() as SVGContext;
}
get divSize(): number {
return this.box.height / SvgPage.modifierDivs;
}
constructor(renderer: Renderer, pageNumber: number, box: SvgBox) {
this._renderer = renderer;
this.pageNumber = pageNumber;
this.box = box;
let divEnd = this.divSize;
for (let i = 0; i < SvgPage.modifierDivs; ++i) {
this.modifierYKeys.push(divEnd);
divEnd += this.divSize;
}
}
/**
* Given SVG y, return the div for modifiers
* @param y
* @returns
*/
divIndex(y: number): number {
return Math.round((y - this.box.y) / this.divSize);
}
/**
* Remove all elements and modifiers in this page, for a redraw.
*/
clearMap() {
this.systemMap = new MappedSystems();
this.modifierTabDivs = {};
}
/**
* Clear mapped objects associated with a measure, including any
* modifiers that span that measure.
* @param selection
*/
clearMeasure(selection: SmoSelection) {
this.systemMap.clearMeasure(selection);
const div = this.divIndex(selection.measure.svg.logicalBox.y);
if (div < this.modifierYKeys.length) {
const mods: ModifierTab[] = [];
this.modifierTabDivs[div].forEach((mt: ModifierTab) => {
if (mt.selection) {
if (!SmoSelector.sameMeasure(mt.selection.selector, selection.selector)) {
mods.push(mt);
}
} else {
mods.push(mt);
}
});
this.modifierTabDivs[div] = mods;
}
}
/**
* add a modifier to the page, indexed by its rectangle
* @param modifier
*/
addModifierTab(modifier: ModifierTab) {
const div = this.divIndex(modifier.box.y);
if (div < this.modifierYKeys.length) {
if (!this.modifierTabDivs[div]) {
this.modifierTabDivs[div] = [];
}
this.modifierTabDivs[div].push(modifier);
}
}
/**
* Add a new selection to the page
* @param selection
*/
addArtifact(selection: SmoSelection) {
this.systemMap.addArtifact(selection);
}
/**
* Try to find a selection on this page, based on the mouse event
* @param box
* @returns
*/
findArtifact(box: SvgBox): SmoSelection[] {
return this.systemMap.findArtifact(box);
}
/**
* Try to find a modifier on this page, based on the mouse event
* @param box
* @returns
*/
findModifierTabs(box: SvgBox): ModifierTab[] {
const rv:ModifierTab[] = [];
const div = this.divIndex(box.y);
if (div < this.modifierYKeys.length) {
if (this.modifierTabDivs[div]) {
this.modifierTabDivs[div].forEach((modTab) => {
if (SvgHelpers.doesBox1ContainBox2(modTab.box, box)) {
rv.push(modTab);
}
});
}
}
return rv;
}
clearModifiers() {
Object.keys(this.modifierTabDivs).forEach((key) => {
const modifiers = this.modifierTabDivs[parseInt(key)];
modifiers.forEach((mod) => {
if (mod instanceof SmoTextGroup) {
(mod as SmoTextGroup).elements.forEach((element) => {
RemoveElementLike(element);
});
(mod as SmoTextGroup).elements = [];
}
});
});
this.modifierTabDivs = {};
}
/**
* Measure the bounding box of an element. Return the box as if the top of the first page were 0,0.
* Bounding boxes are stored in absolute coordinates from the top of the first page. When rendering
* elements, we adjust the coordinates for hte local page.
* @param element
* @returns
*/
offsetBbox(element: ElementLike): SvgBox {
if (element === null) {
throw('invalidBBox in offsetBbox');
}
const yoff = this.box.y;
const xoff = this.box.x;
const lbox = element.getBBox();
return ({ x: lbox.x + xoff, y: lbox.y + yoff, width: lbox.width, height: lbox.height });
}
/**
* Adjust the bounding box to local coordinates for this page.
* @param box
* @returns
*/
offsetSvgBox(box: SvgBox) {
return { x: box.x - this.box.x, y: box.y - this.box.y, width: box.width, height: box.height };
}
/**
* Adjust the point to local coordinates for this page.
* @param box
* @returns
*/
offsetSvgPoint(box: SvgPoint) {
return { x: box.x - this.box.x, y: box.y - this.box.y };
}
get svg(): SVGSVGElement {
return this.getContext().svg as SVGSVGElement;
}
}
/**
* A container for all the SVG elements, and methods to manage adding and finding elements. Each
* page of the score has its own SVG element.
* @category SuiRender
*/
export class SvgPageMap {
_layout: SmoGlobalLayout;
_container: HTMLElement;
_pageLayouts: SmoPageLayout[];
vfRenderers: SvgPage[] = [];
static get strokes(): Record<string, StrokeInfo> {
return {
'debug-mouse-box': {
strokeName: 'debug-mouse',
stroke: '#7ce',
strokeWidth: 3,
strokeDasharray: '1,1',
fill: 'none',
opacity: 0.6
}
};
}
containerOffset: SvgPoint = SvgPoint.default;
/**
*
* @param layout - defines the page width/height and relative zoom common to all the pages
* @param container - the parent DOM element that contains all the pages
* @param pages - the layouts (margins, etc) for each pages.
*/
constructor(layout: SmoGlobalLayout, container: HTMLElement, pages: SmoPageLayout[]) {
this._layout = layout;
this._container = container;
this._pageLayouts = pages;
}
get container() {
return this._container;
}
/**
* Update the offset of the music container DOM element, in client coordinates. This is used
* when converting absolute screen coordinates (like from a mouse event) to SVG coordinates
* @param scrollPoint
*/
updateContainerOffset(scrollPoint: SvgPoint) {
const rect = SvgHelpers.smoBox(this.container.getBoundingClientRect());
this.containerOffset = { x: rect.x + scrollPoint.x, y: rect.y + scrollPoint.y };
}
get layout() {
return this._layout;
}
get pageLayouts() {
return this._pageLayouts;
}
get zoomScale() {
return this.layout.zoomScale;
}
get renderScale() {
return this.layout.svgScale;
}
get pageDivHeight() {
return this.layout.pageHeight * this.zoomScale;
}
get pageDivWidth() {
return this.layout.pageWidth * this.zoomScale;
}
get pageHeight() {
return this.layout.pageHeight / this.layout.svgScale;
}
get pageWidth() {
return this.layout.pageWidth / this.layout.svgScale;
}
get totalHeight() {
return this.pageDivHeight * this.pageLayouts.length;
}
/**
* create/re-create all the page SVG elements
*/
createRenderers() {
// $(this.container).html('');
$(this.container).css('width', '' + Math.round(this.pageDivWidth) + 'px');
$(this.container).css('height', '' + Math.round(this.totalHeight) + 'px');
const toRemove: HTMLElement[] = [];
this.vfRenderers.forEach((renderer) => {
const container = (renderer.svg as SVGSVGElement).parentElement;
if (container) {
toRemove.push(container);
}
});
toRemove.forEach((tt) => {
tt.remove();
});
this.vfRenderers = [];
this.pageLayouts.forEach(() => {
this.addPage();
});
}
addPage() {
const ix = this.vfRenderers.length;
const container = document.createElement('div');
container.setAttribute('id', 'smoosic-svg-div-' + ix.toString());
this._container.append(container);
const vexRenderer = new VF.Renderer(container, VF.Renderer.Backends.SVG);
const svg = (vexRenderer.getContext() as any).svg as SVGSVGElement;
SvgHelpers.svgViewport(svg, 0, 0, this.pageDivWidth, this.pageDivHeight, this.renderScale * this.zoomScale);
const topY = this.pageHeight * ix;
const box = SvgHelpers.boxPoints(0, topY, this.pageWidth, this.pageHeight);
const page = new SvgPage(vexRenderer, ix, box);
this.vfRenderers.push(page);
if (layoutDebug.mask & layoutDebug.values.page) {
const dbgBox = JSON.parse(JSON.stringify(box));
const dims = this.pageLayouts[ix];
dbgBox.x += dims.leftMargin;
dbgBox.width -= (dims.leftMargin + dims.rightMargin);
dbgBox.y += dims.topMargin - topY;
dbgBox.height -= (dims.topMargin + dims.bottomMargin);
SvgHelpers.debugBox(page.svg, dbgBox, layoutDebug.classes[layoutDebug.values.page], 0);
}
}
updateZoom(zoomScale: number) {
this.layout.zoomScale = zoomScale;
this.vfRenderers.forEach((pp) => {
SvgHelpers.svgViewport(pp.svg, 0, 0, this.pageDivWidth, this.pageDivHeight, this.renderScale * this.zoomScale);
});
$(this.container).css('width', '' + Math.round(this.pageDivWidth) + 'px');
$(this.container).css('height', '' + Math.round(this.totalHeight) + 'px');
}
/**
* Convert from screen/client event to SVG space. We assume the scroll offset is already added to `box`
* @param box
* @returns
*/
clientToSvg(box: SvgBox) {
const cof = (this.zoomScale * this.renderScale);
const x = (box.x - this.containerOffset.x) / cof;
const y = (box.y - this.containerOffset.y) / cof;
const logicalBox = SvgHelpers.boxPoints(x, y, Math.max(box.width / cof, 1), Math.max(box.height / cof, 1));
if (layoutDebug.mask | layoutDebug.values['mouseDebug']) {
layoutDebug.updateMouseDebug(box, logicalBox, this.containerOffset);
}
return logicalBox;
}
/**
* Convert from SVG bounding box to screen coordinates
* @param box
* @returns
*/
svgToClient(box: SvgBox) {
const cof = (this.zoomScale * this.renderScale);
const x = (box.x * cof) + this.containerOffset.x;
const y = (box.y * cof) + this.containerOffset.y;
const clientBox = SvgHelpers.boxPoints(x, y, box.width * cof, box.height * cof);
return clientBox;
}
/**
* Convert from SVG bounding box to screen coordinates
* @param box
* @returns
*/
svgToClientNoOffset(box: SvgBox) {
const cof = (this.zoomScale * this.renderScale);
const x = (box.x * cof);
const y = (box.y * cof);
const clientBox = SvgHelpers.boxPoints(x, y, box.width * cof, box.height * cof);
return clientBox;
}
/**
* Find a selection from a mouse event
* @param box - location of a mouse event or specific screen coordinates
* @returns
*/
findArtifact(logicalBox: SvgBox): { selections: SmoSelection[], page: SvgPage} {
const selections: SmoSelection[] = [];
const page = this.getRenderer(logicalBox);
if (page) {
return { selections: page.findArtifact(logicalBox), page };
}
return { selections, page: this.vfRenderers[0] };
}
/**
* Find any modifiers intersecting with `box`
* @param box
* @returns
*/
findModifierTabs(logicalBox: SvgBox): ModifierTab[] {
const page = this.getRenderer(logicalBox);
if (page) {
return page.findModifierTabs(logicalBox);
}
return [];
}
/**
* add a rendered page to the page map
* @param selection
* @returns
*/
addArtifact(selection: SmoSelection) {
if (!selection.note || !selection.note.logicalBox) {
return;
}
const page = this.getRenderer(selection.note.logicalBox);
if (page) {
page.addArtifact(selection);
}
}
/**
* add a rendered modifier to the page map
* @param modifier
*/
addModifierTab(modifier: ModifierTab) {
const page = this.getRenderer(modifier.box);
if (page) {
page.addModifierTab(modifier);
}
}
clearModifiersForPage(page: number) {
if (this.vfRenderers.length > page) {
this.vfRenderers[page].clearModifiers();
}
}
/**
* The number of pages is changing, remove the last page
* @returns
*/
removePage() {
let i = 0;
// Don't remove the only page
if (this.vfRenderers.length < 2) {
return;
}
// Remove last page div
const elementId = 'smoosic-svg-div-' + (this.vfRenderers.length - 1).toString();
const container = document.getElementById(elementId);
if (container) {
container.remove();
}
// pop last renderer off the stack.
const renderers = [];
const layouts = [];
for (i = 0; i < this.vfRenderers.length - 1; ++i) {
renderers.push(this.vfRenderers[i]);
layouts.push(this.pageLayouts[i]);
}
this.vfRenderers = renderers;
this._pageLayouts = layouts;
// update page height
const totalHeight = this.pageDivHeight * this.pageLayouts.length ;
$(this.container).css('width', '' + Math.round(this.pageDivWidth) + 'px');
$(this.container).css('height', '' + Math.round(totalHeight) + 'px');
}
/**
* The score dimensions have changed, clear maps and recreate the pages.
* @param layout
* @param pageLayouts
*/
updateLayout(layout: SmoGlobalLayout, pageLayouts: SmoPageLayout[]) {
this._layout = layout;
this._pageLayouts = pageLayouts;
this.createRenderers();
}
/**
* Return the page by index
* @param page
* @returns
*/
getRendererForPage(page: number) {
if (this.vfRenderers.length > page) {
return this.vfRenderers[page];
}
return this.vfRenderers[this.vfRenderers.length - 1];
}
/**
* Return the SvgPage based on SVG point (conversion from client coordinates already done)
* @param point
* @returns
*/
getRendererFromPoint(point: SvgPoint): SvgPage | null {
const ix = Math.floor(point.y / (this.layout.pageHeight / this.layout.svgScale));
if (ix < this.vfRenderers.length) {
return this.vfRenderers[ix];
}
return null;
}
/**
* Return the SvgPage based on SVG point (conversion from client coordinates already done)
* @param box
* @returns
*/
getRenderer(box: SvgBox | SvgPoint): SvgPage {
const rv = this.getRendererFromPoint({ x: box.x, y: box.y });
if (rv) {
return rv;
}
return this.vfRenderers[0];
}
/**
* Return the page based on the coordinates of a modifier
* @param modifier
* @returns
*/
getRendererFromModifier(modifier?: Renderable) {
let rv = this.vfRenderers[0];
if (modifier && modifier.logicalBox) {
const context = this.getRenderer(modifier.logicalBox);
if (context) {
rv = context;
}
}
return rv;
}
}