636 lines
20 KiB
TypeScript
636 lines
20 KiB
TypeScript
// Simulator main class of the LogicSimulator
|
|
// Copyright (C) 2022 Sascha Nitsch
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
interface Window {
|
|
[propName: string]: any;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
class Simulator {
|
|
private svg: SVGElement;
|
|
private parent: HTMLElement;
|
|
private root: SVGGElement;
|
|
private componentsToLoad: string[];
|
|
private onComponentsLoadedCallback: null;
|
|
private components: Map<string, BaseComponent>;
|
|
private wires: Wire[];
|
|
private mode: string;
|
|
private type: string;
|
|
private basepath: string;
|
|
private runInit: Array<Array<String>>;
|
|
private cors: boolean;
|
|
private ajax: boolean;
|
|
private zoom: number;
|
|
private x: number;
|
|
private y: number;
|
|
private zoomlevel: number[] = [0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5];
|
|
private json: Object;
|
|
private boundkeypress: any;
|
|
private loading: Map<string, Array<[any, any, any]>> = new Map<string, Array<[any, any, any]>>();
|
|
private isFullScreen = false;
|
|
private moveStart: Array<number> = [0, 0];
|
|
private moving: any = undefined;
|
|
|
|
constructor(parent: string, width: string, height: string, className: string, zoom: number) {
|
|
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
if (width.indexOf === undefined || width.indexOf('%') === -1) width += 'px';
|
|
this.svg.setAttribute('width', width);
|
|
this.svg.setAttribute('height', height + 'px');
|
|
if (!className) {
|
|
className = 'logic';
|
|
}
|
|
this.svg.setAttribute('class', className);
|
|
const p = document.getElementById(parent);
|
|
if (p === null) {
|
|
throw new TypeError('parent element not found');
|
|
}
|
|
this.parent = p;
|
|
this.parent.appendChild(this.svg);
|
|
this.root = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
this.svg.appendChild(this.root);
|
|
this.componentsToLoad = [];
|
|
this.onComponentsLoadedCallback = null;
|
|
this.components = new Map<string, BaseComponent>();
|
|
this.wires = [];
|
|
this.mode = 'manual';
|
|
this.type = className;
|
|
this.basepath = '';
|
|
this.runInit = [];
|
|
const xhrSupported = this.xhr();
|
|
this.cors = xhrSupported !== null && 'withCredentials' in xhrSupported;
|
|
this.ajax = xhrSupported !== null;
|
|
if (zoom) {
|
|
this.zoom = 0;
|
|
while (this.zoom < this.zoomlevel.length + 1 && this.zoomlevel[this.zoom + 1] <= zoom) {
|
|
++this.zoom;
|
|
}
|
|
} else {
|
|
this.zoom = 5;
|
|
}
|
|
this.x = this.y = 0;
|
|
this.json = {};
|
|
}
|
|
|
|
close(): void {
|
|
for (const i in this.components) {
|
|
const c = this.components.get(i);
|
|
if (c !== undefined) c.close();
|
|
}
|
|
if (this.svg.parentNode !== null) {
|
|
this.svg.parentNode.removeChild(this.svg);
|
|
}
|
|
window.removeEventListener('keydown', this.boundkeypress);
|
|
}
|
|
|
|
setBasePath(path: string): void {
|
|
this.basepath = path;
|
|
}
|
|
|
|
callback(scope: any, funct: any, parameter: any) {
|
|
if (!scope) {
|
|
funct.call(this, parameter);
|
|
} else {
|
|
funct.call(scope, parameter);
|
|
}
|
|
}
|
|
|
|
loadJS(file: string, scope: any, callback: any, parameter: any) {
|
|
const f = this.loading.get(file);
|
|
if (f !== undefined) {
|
|
f.push([scope, callback, parameter]);
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.setAttribute('src', this.basepath + 'components/' + file + '.min.js');
|
|
this.loading.set(file, [[scope, callback, parameter]]);
|
|
if (callback) {
|
|
script.onload = () => {
|
|
this.callbackList(file);
|
|
};
|
|
}
|
|
document.getElementsByTagName('head')[0].appendChild(script);
|
|
}
|
|
|
|
callbackList(file: string) {
|
|
const f = this.loading.get(file);
|
|
if (f === undefined) return;
|
|
for (let i = 0; i < f.length; ++i) {
|
|
const c = f[i];
|
|
if (c[0]) {
|
|
this.callback(c[0], c[1], c[2]);
|
|
}
|
|
}
|
|
}
|
|
|
|
loadSVG(file: string, scope: any, callback: any, parameter: any) {
|
|
const obj = document.createElement('object');
|
|
obj.setAttribute('data', this.basepath + 'components/' + file + '.svg');
|
|
obj.setAttribute('id', file);
|
|
obj.setAttribute('type', 'image/svg+xml');
|
|
obj.setAttribute('class', 'invisible');
|
|
if (callback) {
|
|
obj.onload = (e: any) => {
|
|
if (!e.target.loaded) {
|
|
e.target.loaded = true;
|
|
this.callback(scope, callback, parameter);
|
|
}
|
|
};
|
|
}
|
|
document.getElementsByTagName('body')[0].appendChild(obj);
|
|
}
|
|
|
|
loadCSS(file: string): void {
|
|
const link = document.createElement('link');
|
|
link.setAttribute('href', this.basepath + 'components/' + file + '.css');
|
|
link.setAttribute('rel', 'stylesheet');
|
|
link.setAttribute('type', 'text/css');
|
|
document.getElementsByTagName('head')[0].appendChild(link);
|
|
}
|
|
|
|
loadComponent(classname: string, loadsvg: boolean): void {
|
|
if (!(classname in window) && this.componentsToLoad.indexOf(classname + 'js') === -1) {
|
|
this.componentsToLoad.push(classname + 'js');
|
|
if (loadsvg) {
|
|
this.componentsToLoad.push(classname + 'svg');
|
|
}
|
|
this.loadJS(classname.toLowerCase(), this, this.componentLoaded, classname + 'js');
|
|
if (loadsvg) {
|
|
this.loadSVG(classname.toLowerCase(), this, this.componentLoaded, classname + 'svg');
|
|
}
|
|
}
|
|
}
|
|
|
|
componentLoaded(classname: string): void {
|
|
for (let i = 0; i < this.componentsToLoad.length; ++i) {
|
|
if (this.componentsToLoad[i] === classname) {
|
|
this.componentsToLoad.splice(i, 1);
|
|
break;
|
|
}
|
|
}
|
|
const cn = classname.substr(0, classname.length - 2);
|
|
for (let j = 0; j < this.runInit.length; ++j) {
|
|
const init = this.runInit[j][1] + 'Init';
|
|
if (this.runInit[j][0] === cn && typeof window[init] === 'function') {
|
|
window[init]();
|
|
this.runInit.splice(j, 1);
|
|
--j;
|
|
}
|
|
}
|
|
if (typeof window[cn + 'Depends'] === 'function') {
|
|
const depends = window[cn + 'Depends']();
|
|
if (!window[depends]) {
|
|
if (this.componentsToLoad.indexOf(depends + 'js') === -1) {
|
|
this.componentsToLoad.push(depends + 'js');
|
|
this.loadJS(depends.toLowerCase(), this, this.componentLoaded, depends + 'js');
|
|
}
|
|
this.runInit.push([depends, cn]);
|
|
} else {
|
|
if (window[classname + 'Init']) {
|
|
window[classname + 'Init']();
|
|
}
|
|
}
|
|
}
|
|
if (this.componentsToLoad.length === 0) {
|
|
this.callback(this, this.onComponentsLoadedCallback, null);
|
|
}
|
|
}
|
|
|
|
onComponentsLoaded(callback: any) {
|
|
if (this.componentsToLoad.length === 0) {
|
|
this.onComponentsLoadedCallback = null;
|
|
this.callback(this, callback, null);
|
|
} else {
|
|
this.onComponentsLoadedCallback = callback;
|
|
}
|
|
}
|
|
|
|
add(id: string | null, name: string, param: any): BaseComponent | null {
|
|
if (!(name in window)) {
|
|
console.log('no such component:', name);
|
|
return null;
|
|
}
|
|
if (id === null) {
|
|
do {
|
|
id = makeId(10);
|
|
} while (id in this.components);
|
|
}
|
|
const component = new (<any>window[name])(this, id, param);
|
|
this.components.set(id, component);
|
|
return component;
|
|
}
|
|
|
|
wire(id: string | null, points: any, param?: any) {
|
|
const wire = new Wire(id, param, this.type === 'physical');
|
|
wire.connect(points);
|
|
this.wires.push(wire);
|
|
}
|
|
|
|
svgButton(parent: SVGElement, x: string, y: string, label: string, onclick: Function) {
|
|
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
g.setAttribute('transform', 'translate(' + x + ',' + y + ')');
|
|
g.setAttribute('data-y', y);
|
|
g.setAttribute('class', 'zoombutton');
|
|
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
rect.setAttribute('x', '0');
|
|
rect.setAttribute('y', '0');
|
|
rect.setAttribute('width', '20');
|
|
rect.setAttribute('height', '20');
|
|
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
text.setAttribute('y', '16');
|
|
const txt = document.createTextNode(label);
|
|
text.appendChild(txt);
|
|
g.appendChild(rect);
|
|
g.appendChild(text);
|
|
parent.appendChild(g);
|
|
if (onclick) {
|
|
g.addEventListener('click', onclick.bind(this));
|
|
}
|
|
const bbox = text.getBBox();
|
|
text.setAttribute('x', ((20 - bbox.width) / 2).toString());
|
|
}
|
|
|
|
svgZoomIn() {
|
|
if (this.zoom < this.zoomlevel.length - 1) {
|
|
++this.zoom;
|
|
this.svgTransform();
|
|
}
|
|
}
|
|
|
|
svgZoomOut() {
|
|
if (this.zoom > 0) {
|
|
--this.zoom;
|
|
this.svgTransform();
|
|
}
|
|
}
|
|
|
|
svgFullScreen(): void {
|
|
this.isFullScreen = !this.isFullScreen;
|
|
let oldWidth;
|
|
let width;
|
|
if (this.isFullScreen) {
|
|
this.svg.setAttribute('style', 'position:fixed; left:0; top:0; width:100%; height:100%;z-index:1000');
|
|
oldWidth = this.svg.getBoundingClientRect().width;
|
|
width = document.body.clientWidth;
|
|
} else {
|
|
this.svg.removeAttribute('style');
|
|
width = this.svg.getBoundingClientRect().width;
|
|
oldWidth = document.body.clientWidth;
|
|
}
|
|
const buttons = this.svg.querySelectorAll('.zoombutton');
|
|
for (let i = 0; i < buttons.length; ++i) {
|
|
buttons[i].setAttribute('transform', 'translate(' + (width - 21) + ',' + buttons[i].getAttribute('data-y') + ')');
|
|
}
|
|
// calc new zoom
|
|
const zoom = (this.zoomlevel[this.zoom] * width) / oldWidth;
|
|
this.zoom = 0;
|
|
while (this.zoom < this.zoomlevel.length + 1 && this.zoomlevel[this.zoom + 1] <= zoom) {
|
|
++this.zoom;
|
|
}
|
|
this.svgTransform();
|
|
}
|
|
|
|
svgTransform() {
|
|
this.root.setAttribute(
|
|
'transform',
|
|
'translate(' + this.x + ',' + this.y + '), scale(' + this.zoomlevel[this.zoom] + ')'
|
|
);
|
|
}
|
|
|
|
startMove(event: MouseEvent) {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
this.moveStart = [event.clientX, event.clientY];
|
|
if (this.moving) {
|
|
this.svg.removeEventListener('mousemove', this.moving);
|
|
}
|
|
this.moving = this.doMove.bind(this);
|
|
this.svg.addEventListener('mousemove', this.moving);
|
|
}
|
|
|
|
endMove() {
|
|
this.svg.removeEventListener('mousemove', this.moving);
|
|
delete this.moving;
|
|
}
|
|
|
|
doMove(event: MouseEvent) {
|
|
this.x += event.clientX - this.moveStart[0];
|
|
this.y += event.clientY - this.moveStart[1];
|
|
this.moveStart = [event.clientX, event.clientY];
|
|
this.svgTransform();
|
|
}
|
|
|
|
mouseScroll(event: any) {
|
|
if (!event.ctrlKey) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (event.deltaY > 0) {
|
|
this.svgZoomOut();
|
|
} else {
|
|
this.svgZoomIn();
|
|
}
|
|
}
|
|
|
|
keypress(event: KeyboardEvent) {
|
|
if (event.altKey) {
|
|
if (event.key === '-') {
|
|
event.preventDefault();
|
|
this.svgZoomOut();
|
|
} else if (event.key === '+') {
|
|
event.preventDefault();
|
|
this.svgZoomIn();
|
|
}
|
|
}
|
|
}
|
|
|
|
run() {
|
|
//create zoom and fullscreeen buttons
|
|
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
g.setAttribute('class', 'labelbg');
|
|
this.svg.appendChild(g);
|
|
const width = this.svg.getBoundingClientRect().width;
|
|
this.svgButton(g, (width - 21).toString(), '1', '+', this.svgZoomIn);
|
|
this.svgButton(g, (width - 21).toString(), '23', '-', this.svgZoomOut);
|
|
this.svgButton(g, (width - 21).toString(), '46', '⇔', this.svgFullScreen);
|
|
this.svg.addEventListener('mousedown', this.startMove.bind(this));
|
|
this.svg.addEventListener('mouseup', this.endMove.bind(this));
|
|
this.svg.addEventListener('wheel', this.mouseScroll.bind(this));
|
|
this.boundkeypress = this.keypress.bind(this);
|
|
window.addEventListener('keydown', this.boundkeypress);
|
|
this.svgTransform();
|
|
for (let i = 0; i < this.wires.length; ++i) {
|
|
this.wires[i].update();
|
|
}
|
|
this.tick();
|
|
}
|
|
|
|
manualtick(): void {
|
|
if (this.mode === 'manual') {
|
|
this.tick();
|
|
}
|
|
}
|
|
|
|
tick(): void {
|
|
let i;
|
|
let instantUpdate = false;
|
|
let reruns = 10;
|
|
do {
|
|
instantUpdate = false;
|
|
this.components.forEach((c: BaseComponent) => {
|
|
c.io();
|
|
});
|
|
this.components.forEach((c: BaseComponent) => {
|
|
instantUpdate = c.update() || instantUpdate;
|
|
});
|
|
for (i = 0; i < this.wires.length; ++i) {
|
|
instantUpdate = this.wires[i].update() || instantUpdate;
|
|
}
|
|
} while (instantUpdate && --reruns > 0);
|
|
}
|
|
|
|
fromJson(json: Object) {
|
|
this.json = json;
|
|
// round 1, extract components
|
|
let id: keyof typeof json;
|
|
for (id in json) {
|
|
const j = <any>json[id];
|
|
const component = j.component;
|
|
if (component !== 'Wire') {
|
|
if ('svg' in j) {
|
|
this.componentsToLoad.push(component + 'svg');
|
|
this.loadSVG(j.svg.toLowerCase(), this, this.componentLoaded, component + 'svg');
|
|
this.loadComponent(component, false);
|
|
} else {
|
|
this.loadComponent(component, j.nosvg !== true);
|
|
}
|
|
}
|
|
}
|
|
this.onComponentsLoaded(this.jsonSetup);
|
|
}
|
|
|
|
xhr(): XMLHttpRequest | null {
|
|
try {
|
|
return new window.XMLHttpRequest();
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
fromJsonFile(url: string) {
|
|
// load json
|
|
let xhr = null;
|
|
if (this.ajax && this.cors) {
|
|
xhr = this.xhr();
|
|
if (xhr !== null) {
|
|
xhr.open('get', url);
|
|
}
|
|
}
|
|
if (xhr === null) {
|
|
return;
|
|
}
|
|
xhr.onreadystatechange = function (this: Simulator, a: Event) {
|
|
if (a === null || a.target === null) return;
|
|
const target = <XMLHttpRequest>a.target;
|
|
if (target.readyState === 4 && target.status === 200) {
|
|
const json = JSON.parse(target.responseText);
|
|
this.fromJson(json);
|
|
}
|
|
}.bind(this);
|
|
// Send the request
|
|
xhr.send(null);
|
|
}
|
|
|
|
jsonSetup() {
|
|
const components = new Map<string, BaseComponent>();
|
|
//round 2, create and place components
|
|
let json: any;
|
|
let id: keyof Object;
|
|
for (id in this.json) {
|
|
json = this.json[id];
|
|
if (json.component !== 'Wire') {
|
|
const component = this.add(id, json.component, json.param);
|
|
if (component === null) {
|
|
continue;
|
|
}
|
|
if (json.position && !component.setPosition) {
|
|
console.log('error on component', component, json);
|
|
}
|
|
if (json.position && component.setPosition) {
|
|
if (typeof json.position === 'string') {
|
|
const pos = json.position.split('.');
|
|
const c = components.get(pos[0]);
|
|
if (c !== undefined) {
|
|
component.setPosition(c, pos[1]);
|
|
}
|
|
} else {
|
|
component.setPosition(json.position[0], json.position[1]);
|
|
}
|
|
}
|
|
if (json.label) {
|
|
component.showLabel(json.label[0], json.label[1], json.label.length === 3 ? json.label[2] : undefined);
|
|
}
|
|
if (json.ref) {
|
|
component.showRef(json.ref[0], json.ref[1], json.ref[2]);
|
|
}
|
|
if (json.startdelay) {
|
|
component.setStartDelay(json.startdelay);
|
|
}
|
|
components.set(id, component);
|
|
}
|
|
}
|
|
this.components.forEach(function (this: Simulator, c: BaseComponent) {
|
|
if (c.zIndex === 0) {
|
|
c.setup(this.root);
|
|
}
|
|
}, this);
|
|
// round 3, add wiring
|
|
let i;
|
|
let tokens;
|
|
let obj;
|
|
for (id in this.json) {
|
|
json = this.json[id];
|
|
if (json.component === 'Wire') {
|
|
if (json.path) {
|
|
json.paths = [json.path];
|
|
}
|
|
const base = json.base;
|
|
if (json.paths) {
|
|
for (let p = 0; p < json.paths.length; ++p) {
|
|
const path = json.paths[p];
|
|
for (i = 0; i < path.length; ++i) {
|
|
if (typeof path[i] === 'string') {
|
|
tokens = path[i].split('.');
|
|
if (tokens[0] === '') {
|
|
tokens[0] = base;
|
|
}
|
|
obj = components.get(tokens[0]);
|
|
if (obj !== undefined) {
|
|
path[i] = obj.getPin(tokens[1]);
|
|
} else {
|
|
console.log('could not find ', tokens[0], '.');
|
|
}
|
|
} else if (typeof path[i] === 'number') {
|
|
if (path[path[i]] instanceof TriState) {
|
|
const t = path[path[i]];
|
|
path[i] = [t.x(), t.y()];
|
|
}
|
|
}
|
|
}
|
|
let rerun = true;
|
|
let reruns = 0;
|
|
while (rerun && reruns < 100) {
|
|
let other;
|
|
++reruns;
|
|
rerun = false;
|
|
for (i = 0; i < path.length; ++i) {
|
|
if (path[i] && typeof path[i] === 'object') {
|
|
let pa;
|
|
if (typeof path[i][0] === 'string') {
|
|
// relative
|
|
const offsetX = path[i][0].length > 1 ? parseInt(path[i][0].substr(1)) : 0;
|
|
pa = path[i][0][0];
|
|
if (pa === 'b') {
|
|
const c = components.get(base);
|
|
if (c !== undefined) {
|
|
path[i][0] = c.getX() + offsetX;
|
|
}
|
|
} else {
|
|
other = pa === 'p' ? i - 1 : i + 1;
|
|
if (path[other] instanceof TriState) {
|
|
path[i][0] = path[other].x() + offsetX;
|
|
} else {
|
|
if (typeof path[other] === 'number' || typeof path[other][0] === 'string') {
|
|
rerun = true;
|
|
} else {
|
|
path[i][0] = path[other][0] + offsetX;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (typeof path[i][1] === 'string') {
|
|
// relative
|
|
const offsetY = path[i][1].length > 1 ? parseInt(path[i][1].substr(1)) : 0;
|
|
pa = path[i][1][0];
|
|
if (pa === 'b') {
|
|
const c = components.get(base);
|
|
if (c !== undefined) {
|
|
path[i][1] = c.getY() + offsetY;
|
|
}
|
|
} else {
|
|
other = pa === 'p' ? i - 1 : i + 1;
|
|
if (path[other] instanceof TriState) {
|
|
path[i][1] = path[other].y() + offsetY;
|
|
} else {
|
|
if (typeof path[other] === 'number' || typeof path[other][1] === 'string') {
|
|
rerun = true;
|
|
} else {
|
|
path[i][1] = path[other][1] + offsetY;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (path[i] && typeof path[i] === 'number') {
|
|
other = path[i];
|
|
if (path[other] instanceof TriState) {
|
|
path[i] = path[other];
|
|
} else {
|
|
if (typeof path[other][0] === 'string' || typeof path[other][1] === 'string') {
|
|
rerun = true;
|
|
} else {
|
|
path[i] = path[other];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.wire(p ? null : id, path, json.param); // first wire gets id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.components.forEach(function (this: Simulator, c: BaseComponent) {
|
|
if (c.zIndex > 0) {
|
|
c.setup(this.root);
|
|
}
|
|
}, this);
|
|
|
|
//finalize component setup
|
|
this.components.forEach((c: BaseComponent) => {
|
|
c.finalize();
|
|
});
|
|
|
|
for (i = 0; i < this.wires.length; ++i) {
|
|
const s = this.wires[i].setup(this.root);
|
|
if (!s) {
|
|
this.wires.splice(i, 1);
|
|
--i;
|
|
}
|
|
}
|
|
this.run();
|
|
}
|
|
getParent(): HTMLElement {
|
|
return this.parent;
|
|
}
|
|
|
|
getType() {
|
|
return this.type;
|
|
}
|
|
}
|