pxt-calliope/sim/visuals/genericboard.ts

515 lines
19 KiB
TypeScript

/// <reference path="../../node_modules/pxt-core/typings/bluebird/bluebird.d.ts"/>
/// <reference path="../../node_modules/pxt-core/built/pxtsim.d.ts"/>
/// <reference path="../../libs/microbit/dal.d.ts"/>
namespace pxsim.visuals {
const svg = pxsim.svg;
export interface IBoardSvgProps {
runtime: pxsim.Runtime;
boardDef: BoardDefinition;
disableTilt?: boolean;
activeComponents: string[];
fnArgs?: any;
componentDefinitions: Map<PartDefinition>;
}
export const VIEW_WIDTH = 498;
export const VIEW_HEIGHT = 725;
const TOP_MARGIN = 20;
const MID_MARGIN = 40;
const BOT_MARGIN = 20;
const PIN_LBL_SIZE = PIN_DIST * 0.7;
const PIN_LBL_HOVER_SIZE = PIN_LBL_SIZE * 1.5;
const SQUARE_PIN_WIDTH = PIN_DIST * 0.66666;
const SQUARE_PIN_HOVER_WIDTH = PIN_DIST * 0.66666 + PIN_DIST / 3.0;
export type ComputedBoardDimensions = {
scaleFn: (n: number) => number,
height: number,
width: number,
xOff: number,
yOff: number
};
export function getBoardDimensions(vis: BoardImageDefinition): ComputedBoardDimensions {
let scaleFn = (n: number) => n * (PIN_DIST / vis.pinDist);
let width = scaleFn(vis.width);
return {
scaleFn: scaleFn,
height: scaleFn(vis.height),
width: width,
xOff: (VIEW_WIDTH - width) / 2.0,
yOff: TOP_MARGIN
}
}
export const BOARD_SYTLE = `
.noselect {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Chrome/Safari/Opera */
-khtml-user-select: none; /* Konqueror */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently
not supported by any browser */
}
svg.sim.grayscale {
-moz-filter: grayscale(1);
-webkit-filter: grayscale(1);
filter: grayscale(1);
}
.sim-text {
font-family:"Lucida Console", Monaco, monospace;
font-size:25px;
fill:#fff;
pointer-events: none;
}
/* animations */
.sim-theme-glow {
animation-name: sim-theme-glow-animation;
animation-timing-function: ease-in-out;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-duration: 1.25s;
}
@keyframes sim-theme-glow-animation {
from { opacity: 1; }
to { opacity: 0.75; }
}
.sim-flash {
animation-name: sim-flash-animation;
animation-duration: 0.1s;
}
@keyframes sim-flash-animation {
from { fill: yellow; }
to { fill: default; }
}
.sim-flash-stroke {
animation-name: sim-flash-stroke-animation;
animation-duration: 0.4s;
animation-timing-function: ease-in;
}
@keyframes sim-flash-stroke-animation {
from { stroke: yellow; }
to { stroke: default; }
}
.sim-board-pin {
fill:#999;
stroke:#000;
stroke-width:${PIN_DIST / 3.0}px;
}
.sim-board-pin-lbl {
fill: #333;
}
.gray-cover {
fill:#FFF;
opacity: 0.7;
stroke-width:0;
visibility: hidden;
}
.sim-board-pin-hover {
visibility: hidden;
pointer-events: all;
stroke-width:${PIN_DIST / 6.0}px;
}
.sim-board-pin-hover:hover {
visibility: visible;
}
.sim-board-pin-lbl {
visibility: hidden;
}
.sim-board-outline .sim-board-pin-lbl {
visibility: visible;
}
.sim-board-pin-lbl {
fill: #555;
}
.sim-board-pin-lbl-hover {
fill: red;
}
.sim-board-outline .sim-board-pin-lbl-hover {
fill: black;
}
.sim-board-pin-lbl,
.sim-board-pin-lbl-hover {
font-family:"Lucida Console", Monaco, monospace;
pointer-events: all;
stroke-width: 0;
}
.sim-board-pin-lbl-hover {
visibility: hidden;
}
.sim-board-outline .sim-board-pin-hover:hover + .sim-board-pin-lbl,
.sim-board-pin-lbl.highlight {
visibility: hidden;
}
.sim-board-outline .sim-board-pin-hover:hover + * + .sim-board-pin-lbl-hover,
.sim-board-pin-lbl-hover.highlight {
visibility: visible;
}
/* Graying out */
.grayed .sim-board-pin-lbl:not(.highlight) {
fill: #AAA;
}
.grayed .sim-board-pin:not(.highlight) {
fill:#BBB;
stroke:#777;
}
.grayed .gray-cover {
visibility: inherit;
}
.grayed .sim-cmp:not(.notgrayed) {
opacity: 0.3;
}
/* Highlighting */
.sim-board-pin-lbl.highlight {
fill: #000;
font-weight: bold;
}
.sim-board-pin.highlight {
fill:#999;
stroke:#000;
}
`;
let nextBoardId = 0;
export class GenericBoardSvg /*TODO: implements BoardView*/ {
public hostElement: SVGSVGElement;
private style: SVGStyleElement;
private defs: SVGDefsElement;
private g: SVGGElement;
public board: pxsim.DalBoard;
public background: SVGElement;
private components: IBoardComponent<any>[];
public breadboard: Breadboard;
private underboard: SVGGElement;
public boardDef: BoardDefinition;
private boardDim: ComputedBoardDimensions;
public componentDefs: Map<PartDefinition>;
private boardEdges: number[];
private id: number;
public bbX: number;
public bbY: number;
private boardTopEdge: number;
private boardBotEdge: number;
private wireFactory: WireFactory;
//truth
private allPins: GridPin[] = [];
private allLabels: GridLabel[] = [];
//cache
private pinNmToLbl: Map<GridLabel> = {};
private pinNmToPin: Map<GridPin> = {};
constructor(public props: IBoardSvgProps) {
this.id = nextBoardId++;
this.boardDef = props.boardDef;
this.boardDim = getBoardDimensions(<BoardImageDefinition>this.boardDef.visual);
this.board = this.props.runtime.board as pxsim.DalBoard;
this.board.updateView = () => this.updateState();
this.hostElement = <SVGSVGElement>svg.elt("svg")
svg.hydrate(this.hostElement, {
"version": "1.0",
"viewBox": `0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`,
"enable-background": `new 0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`,
"class": `sim sim-board-id-${this.id}`,
"x": "0px",
"y": "0px"
});
this.style = <SVGStyleElement>svg.child(this.hostElement, "style", {});
this.style.textContent += BOARD_SYTLE;
this.defs = <SVGDefsElement>svg.child(this.hostElement, "defs", {});
this.g = <SVGGElement>svg.elt("g");
this.hostElement.appendChild(this.g);
this.underboard = <SVGGElement>svg.child(this.g, "g", {class: "sim-underboard"});
this.components = [];
this.componentDefs = props.componentDefinitions;
// breadboard
this.breadboard = new Breadboard({})
this.g.appendChild(this.breadboard.bb);
let bbSize = this.breadboard.getSVGAndSize();
let [bbWidth, bbHeight] = [bbSize.w, bbSize.h];
const bbX = (VIEW_WIDTH - bbWidth) / 2;
this.bbX = bbX;
const bbY = TOP_MARGIN + this.boardDim.height + MID_MARGIN;
this.bbY = bbY;
this.breadboard.updateLocation(bbX, bbY);
// edges
this.boardTopEdge = TOP_MARGIN;
this.boardBotEdge = TOP_MARGIN + this.boardDim.height;
this.boardEdges = [this.boardTopEdge, this.boardBotEdge, bbY, bbY + bbHeight]
this.wireFactory = new WireFactory(this.underboard, this.g, this.boardEdges, this.style, this.getLocCoord.bind(this));
this.buildDom();
this.updateTheme();
this.updateState();
let cmps = props.activeComponents;
if (cmps.length) {
let allocRes = allocateDefinitions({
boardDef: this.boardDef,
cmpDefs: this.componentDefs,
fnArgs: this.props.fnArgs,
getBBCoord: this.getBBCoord.bind(this),
cmpList: props.activeComponents,
});
this.addAll(allocRes);
}
}
private getBoardPinCoord(pinNm: string): Coord {
let pin = this.pinNmToPin[pinNm];
if (!pin)
return null;
return [pin.cx, pin.cy];
}
private getBBCoord(rowCol: BBRowCol): Coord {
let bbCoord = this.breadboard.getCoord(rowCol);
if (!bbCoord)
return null;
let [x, y] = bbCoord;
return [x + this.bbX, y + this.bbY];
}
public getLocCoord(loc: Loc): Coord {
let coord: Coord;
if (loc.type === "breadboard") {
let rowCol = (<BBLoc>loc).rowCol;
coord = this.getBBCoord(rowCol);
} else {
let pinNm = (<BoardLoc>loc).pin;
coord = this.getBoardPinCoord(pinNm);
}
if (!coord) {
console.error("Unknown location: " + name)
return [0, 0];
}
return coord;
}
private mkGrayCover(x: number, y: number, w: number, h: number) {
let rect = <SVGRectElement>svg.elt("rect");
svg.hydrate(rect, {x: x, y: y, width: w, height: h, class: "gray-cover"});
return rect;
}
private getCmpClass = (type: string) => `sim-${type}-cmp`;
public addWire(inst: WireInst): Wire {
return this.wireFactory.addWire(inst.start, inst.end, inst.color);
}
public addAll(basicWiresAndCmpsAndWires: AllocatorResult) {
let {powerWires, components} = basicWiresAndCmpsAndWires;
powerWires.forEach(w => this.addWire(w));
components.forEach((cAndWs, idx) => {
let {component, wires} = cAndWs;
wires.forEach(w => this.addWire(w));
this.addComponent(component);
});
}
public addComponent(cmpDesc: CmpInst): IBoardComponent<any> {
let cmp: IBoardComponent<any> = null;
if (typeof cmpDesc.visual === "string") {
let builtinVisual = cmpDesc.visual as string;
let cnstr = builtinComponentSimVisual[builtinVisual];
let stateFn = builtinComponentSimState[builtinVisual];
let state = stateFn(this.board);
cmp = cnstr();
cmp.init(this.board.bus, state, this.hostElement, cmpDesc.microbitPins, cmpDesc.otherArgs);
this.components.push(cmp);
this.g.appendChild(cmp.element);
if (cmp.defs)
cmp.defs.forEach(d => this.defs.appendChild(d));
this.style.textContent += cmp.style || "";
let rowCol = <BBRowCol>[`${cmpDesc.breadboardStartRow}`, `${cmpDesc.breadboardStartColumn}`];
let coord = this.getBBCoord(rowCol);
cmp.moveToCoord(coord);
let cls = this.getCmpClass(name);
svg.addClass(cmp.element, cls);
svg.addClass(cmp.element, "sim-cmp");
cmp.updateTheme();
cmp.updateState();
} else {
//TODO: adding generic components
}
return cmp;
}
private updateTheme() {
this.components.forEach(c => c.updateTheme());
}
public updateState() {
let state = this.board;
if (!state) return;
this.components.forEach(c => c.updateState());
if (!runtime || runtime.dead) svg.addClass(this.hostElement, "grayscale");
else svg.removeClass(this.hostElement, "grayscale");
}
private buildDom() {
// filters
let glow = svg.child(this.defs, "filter", { id: "filterglow", x: "-5%", y: "-5%", width: "120%", height: "120%" });
svg.child(glow, "feGaussianBlur", { stdDeviation: "5", result: "glow" });
let merge = svg.child(glow, "feMerge", {});
for (let i = 0; i < 3; ++i)
svg.child(merge, "feMergeNode", { in: "glow" })
// main board
this.background = svg.child(this.g, "image",
{ class: "sim-board", x: this.boardDim.xOff, y: this.boardDim.yOff, width: this.boardDim.width, height: this.boardDim.height,
"href": `${(<BoardImageDefinition>this.boardDef.visual).image}`});
let backgroundCover = this.mkGrayCover(this.boardDim.xOff, this.boardDim.yOff, this.boardDim.width, this.boardDim.height);
this.g.appendChild(backgroundCover);
// ----- pins
const mkSquarePin = (): SVGElAndSize => {
let el = svg.elt("rect");
let width = SQUARE_PIN_WIDTH;
svg.hydrate(el, {
class: "sim-board-pin",
width: width,
height: width,
});
return {el: el, w: width, h: width, x: 0, y: 0};
}
const mkSquareHoverPin = (): SVGElAndSize => {
let el = svg.elt("rect");
let width = SQUARE_PIN_HOVER_WIDTH;
svg.hydrate(el, {
class: "sim-board-pin-hover",
width: width,
height: width
});
return {el: el, w: width, h: width, x: 0, y: 0};
}
const mkPinBlockGrid = (pinBlock: PinBlockDefinition, blockIdx: number) => {
let xOffset = this.boardDim.xOff + this.boardDim.scaleFn(pinBlock.x) + PIN_DIST / 2.0;
let yOffset = this.boardDim.yOff + this.boardDim.scaleFn(pinBlock.y) + PIN_DIST / 2.0;
let rowCount = 1;
let colCount = pinBlock.labels.length;
let getColName = (colIdx: number) => pinBlock.labels[colIdx];
let getRowName = () => `${blockIdx + 1}`
let getGroupName = () => pinBlock.labels.join(" ");
let gridRes = mkGrid({
xOffset: xOffset,
yOffset: yOffset,
rowCount: rowCount,
colCount: colCount,
pinDist: PIN_DIST,
mkPin: mkSquarePin,
mkHoverPin: mkSquareHoverPin,
getRowName: getRowName,
getColName: getColName,
getGroupName: getGroupName,
});
let pins = gridRes.allPins;
let pinsG = gridRes.g;
svg.addClass(gridRes.g, "sim-board-pin-group");
return gridRes;
};
let pinBlocks = (<BoardImageDefinition>this.boardDef.visual).pinBlocks.map(mkPinBlockGrid);
pinBlocks.forEach(blk => blk.allPins.forEach(p => {
this.allPins.push(p);
}));
//tooltip
this.allPins.forEach(p => {
let tooltip = p.col;
svg.hydrate(p.el, {title: tooltip});
svg.hydrate(p.hoverEl, {title: tooltip});
});
//attach pins
this.allPins.forEach(p => {
this.g.appendChild(p.el);
this.g.appendChild(p.hoverEl);
});
//catalog pins
this.allPins.forEach(p => {
this.pinNmToPin[p.col] = p;
});
// ----- labels
const mkLabelTxtEl = (pinX: number, pinY: number, size: number, txt: string): SVGTextElement => {
//TODO: extract constants
let lblY: number;
let lblX: number;
let edges = [this.boardTopEdge, this.boardBotEdge];
let distFromTopBot = edges.map(e => Math.abs(e - pinY));
let closestEdgeIdx = distFromTopBot.reduce((pi, n, ni) => n < distFromTopBot[pi] ? ni : pi, 0);
let topEdge = closestEdgeIdx == 0;
if (topEdge) {
let lblLen = size * 0.25 * txt.length;
lblX = pinX;
lblY = pinY + 12 + lblLen;
} else {
let lblLen = size * 0.32 * txt.length;
lblX = pinX;
lblY = pinY - 11 - lblLen;
}
let el = mkTxt(lblX, lblY, size, -90, txt);
return el;
};
const mkLabel = (pinX: number, pinY: number, txt: string): GridLabel => {
let el = mkLabelTxtEl(pinX, pinY, PIN_LBL_SIZE, txt);
svg.addClass(el, "sim-board-pin-lbl");
let hoverEl = mkLabelTxtEl(pinX, pinY, PIN_LBL_HOVER_SIZE, txt);
svg.addClass(hoverEl, "sim-board-pin-lbl-hover");
let label: GridLabel = {el: el, hoverEl: hoverEl, txt: txt};
return label;
}
this.allLabels = this.allPins.map(p => {
return mkLabel(p.cx, p.cy, p.col);
});
//attach labels
this.allLabels.forEach(l => {
this.g.appendChild(l.el);
this.g.appendChild(l.hoverEl);
});
//catalog labels
this.allPins.forEach((pin, pinIdx) => {
let lbl = this.allLabels[pinIdx];
this.pinNmToLbl[pin.col] = lbl;
});
}
public highlightLoc(pinNm: string) {
let lbl = this.pinNmToLbl[pinNm];
let pin = this.pinNmToPin[pinNm];
if (lbl && pin) {
svg.addClass(lbl.el, "highlight");
svg.addClass(lbl.hoverEl, "highlight");
svg.addClass(pin.el, "highlight");
svg.addClass(pin.hoverEl, "highlight");
}
}
public highlightWire(wire: Wire) {
//underboard wires
wire.wires.forEach(e => {
(<any>e).style["visibility"] = "visible";
});
//un greyed out
[wire.end1, wire.end2].forEach(e => {
svg.addClass(e, "highlight");
});
wire.wires.forEach(e => {
svg.addClass(e, "highlight");
});
}
}
}