clean up and edit readme
This commit is contained in:
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 65,
|
||||
"tabWidth": 4
|
||||
}
|
||||
22
Makefile
22
Makefile
@@ -14,18 +14,28 @@ $(BUILD_DIR)/%.js: $(SRC_DIR)/%.ts ./node_modules
|
||||
mkdir -p $(dir $@)
|
||||
npx swc $< -o $@
|
||||
|
||||
# bundle all the templates into $(BUILD_DIR)/index.html
|
||||
# bundle all the templates into $(BUILD_DIR)/index.html and minify
|
||||
$(BUILD_DIR)/index.html: export TEMPLATES = $(shell cat $(HTML_SRCS))
|
||||
$(BUILD_DIR)/index.html: $(HTML_SRCS) $(BUILD_DIR) ./index.template.html
|
||||
cat ./index.template.html | envsubst '$$TEMPLATES' | tr -d '\n' | sed -r 's/\s+/ /g' > $@
|
||||
|
||||
./node_modules:
|
||||
npm install
|
||||
|
||||
package-lock.json:
|
||||
npm install --loglevel=http
|
||||
|
||||
./node_modules: package-lock.json
|
||||
npm ci --loglevel=http
|
||||
|
||||
serve: all
|
||||
npx serve -s $(BUILD_DIR)
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) ./node_modules
|
||||
dev:
|
||||
npx nodemon --watch $(SRC_DIR) --ext ts,html --exec "make clean && make serve"
|
||||
|
||||
.PHONY: all clean serve
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
nuke:
|
||||
rm -rf ./node_modules
|
||||
|
||||
.PHONY: all clean nuke serve dev
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# MEDTRACE: Case - Frontend developer
|
||||
# Web front end
|
||||
|
||||
## build and run
|
||||
## Build and run
|
||||
|
||||
From the repo root, run `make` to build the project. From there you can use your own server to serve from the repo root, or run `make serve` to run a development server. `make clean` will remove the dependencies and build directory.
|
||||
This projectr uses [GNU make](https://www.gnu.org/software/make/) to build and run. From the repo root, run `make` to build the project. From there you can use your own web server to serve from the repo root, or run `make serve` to run a development server. `make clean` will remove the dependencies and build directory.
|
||||
|
||||
Run `make dev` to install all dependencies and run a development server that watches for changes and reloads. (It does not refresh the browser.)
|
||||
|
||||
BIN
heartbeat.webm
BIN
heartbeat.webm
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport"/>
|
||||
<meta name="theme-color" content="#1d1850"/>
|
||||
|
||||
<title>MEDTRACE: Case - Frontend Developer</title>
|
||||
<title>MEDTRACE</title>
|
||||
|
||||
<!-- ability to import three.js from cdn -->
|
||||
<script type="importmap">
|
||||
@@ -25,19 +25,8 @@
|
||||
padding: 0;
|
||||
}
|
||||
:root {
|
||||
/* init the CSS var from the "light" DOM */
|
||||
/* --theme-primary: #b2ff0b;
|
||||
--theme-primary-pale: #90aa00;
|
||||
--theme-secondary: #411dc9;
|
||||
--theme-secondary-hover: #512dd9;
|
||||
--theme-secondary-dark: #020c67;
|
||||
--theme-secondary-dark-hover: #121c7f; */
|
||||
--theme-error: #ff5a58;
|
||||
--theme-error-hover: #ff7a78;
|
||||
/* --theme-background: #1a1a19;
|
||||
--theme-title-color: #fff;
|
||||
--theme-ink-color: #ebeae2;
|
||||
--theme-font-family: "Roboto", sans-serif; */
|
||||
|
||||
--theme-primary: #caa026;
|
||||
--theme-primary-subdued: #846918;
|
||||
@@ -45,7 +34,6 @@
|
||||
--theme-secondary: #251f65;
|
||||
--theme-secondary-hover: #332a8b;
|
||||
--theme-secondary-dark: #1d1850;
|
||||
/* --theme-secondary-dark-hover: #121c7f; */
|
||||
|
||||
--theme-background: #120f32;
|
||||
--theme-background-top-bar: #120f32fa;
|
||||
|
||||
2644
package-lock.json
generated
2644
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@swc/cli": "^0.3.12",
|
||||
"@swc/cli": "^0.7.8",
|
||||
"@swc/core": "^1.4.15",
|
||||
"nodemon": "^3.1.10",
|
||||
"serve": "^14.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
178
src/App/App.html
178
src/App/App.html
@@ -1,179 +1,3 @@
|
||||
<template id="app-template">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Material+Icons&display=block"
|
||||
rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:host {
|
||||
color: var(--main-text-color);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#cards {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 135px);
|
||||
grid-gap: 2rem 0.5rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
a {
|
||||
background-color: var(--theme-secondary-dark);
|
||||
text-decoration: none;
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
a:hover {
|
||||
background-color: var(--theme-secondary-dark-hover);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
.context-menu-item {
|
||||
/* nothing yet */
|
||||
}
|
||||
.context-menu-item > a {
|
||||
display: block;
|
||||
font-size: 25pt;
|
||||
padding: 5px;
|
||||
padding-left: 6px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 4px #55a;
|
||||
color: var(--theme-ink-color);
|
||||
}
|
||||
.context-menu-item > a:hover {
|
||||
box-shadow: 0 0 10px #55a;
|
||||
}
|
||||
|
||||
#main-menu {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#not-found {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#main {
|
||||
margin-bottom: 100px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
#breadcrumbs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 var(--main-side-margin) 0 5px;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#breadcrumbs li {
|
||||
margin: 0 6px 0 0;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
display: none;
|
||||
}
|
||||
#breadcrumbs li.show {
|
||||
display: inline-block;
|
||||
}
|
||||
#breadcrumbs li i {
|
||||
margin: 0 1px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
#breadcrumbs li a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
[slot] paper-button {
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
color: var(--main-text-color);
|
||||
padding: 0 5px 0 0;
|
||||
min-width: 0px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff55;
|
||||
height: 32px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 5px #aaa;
|
||||
box-shadow: 0 0 3px #ddd;
|
||||
box-shadow: none;
|
||||
}
|
||||
[slot] paper-button i {
|
||||
font-size: var(--icon-size);
|
||||
}
|
||||
.context-menu-spacer {
|
||||
border-right: 1px dotted #fff;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
#hamburger {
|
||||
/* display: none; */
|
||||
}
|
||||
#hamburger.show {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
|
||||
#breadcrumbs li.show {
|
||||
padding: 0;
|
||||
/* margin-top: -20px; */
|
||||
font-weight: lighter;
|
||||
}
|
||||
#breadcrumbs li.show:last-child a {
|
||||
/* font-size: 1.5em; */
|
||||
font-weight: bold;
|
||||
}
|
||||
#breadcrumbs li.show a {
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 700px) {
|
||||
#breadcrumbs {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<pj-layout id="layout">
|
||||
<a slot="main-title" href="/"><div id="logo"></div></a>
|
||||
|
||||
<div class="context-menu" slot="context-menu">
|
||||
<div class="context-menu-item">
|
||||
<a id="main-menu" class="material-icons" tabindex="0"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main" slot="main">
|
||||
<pj-pages id="pages" attr-for-selected="name" default-selection="not-found">
|
||||
<div name="cards">
|
||||
<section id="cards">
|
||||
<my-card>
|
||||
<span slot="headline">Custom headline</span>
|
||||
<button id="my-button">click me</button>
|
||||
</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ecg-ticker name="patients" heart-rate="60"></ecg-ticker>
|
||||
<h1 name="not-found" id="not-found">Not found</h1>
|
||||
<h1 name="bad-route">Bad route</h1>
|
||||
<h1 name="not-implemented">Not implemented yet</h1>
|
||||
<h1 name="server-error">Server error</h1>
|
||||
</pj-pages>
|
||||
</div>
|
||||
|
||||
<pj-nav id="nav" slot="right-drawer" display-mode="stack"></pj-nav>
|
||||
</pj-layout>
|
||||
<ecg-monitor name="patients"></ecg-monitor>
|
||||
</template>
|
||||
186
src/App/App.ts
186
src/App/App.ts
@@ -1,192 +1,22 @@
|
||||
import "/Pages/Pages.js";
|
||||
import Pages from "../Pages/Pages.js";
|
||||
import "../ECGMonitor/ECGMonitor.js";
|
||||
|
||||
import "../Layout/Layout.js";
|
||||
import Layout from "../Layout/Layout.js";
|
||||
const template = document.getElementById("app-template") as HTMLTemplateElement;
|
||||
|
||||
import "../Nav/Nav.js";
|
||||
import Nav, { MenuItem } from "../Nav/Nav.js";
|
||||
|
||||
import Route from "../Route/Route.js";
|
||||
import * as utils from "../utils.js";
|
||||
|
||||
import "../MyCard/MyCard.js";
|
||||
|
||||
// import materialIcons from "../../material-icons-link.html";
|
||||
// import commonCSS from "../styles/common.css";
|
||||
// import logo from "../../assets/logo.svg";
|
||||
|
||||
const template = document.getElementById("app-template") as
|
||||
HTMLTemplateElement;
|
||||
|
||||
const navigationItems: MenuItem[] = [
|
||||
{
|
||||
name: "cards",
|
||||
label: "Cards",
|
||||
href: "/cards/",
|
||||
icon: "library_books",
|
||||
spa: true,
|
||||
},
|
||||
{
|
||||
name: "patients",
|
||||
label: "Patients",
|
||||
href: "/patients/",
|
||||
icon: "face",
|
||||
spa: true,
|
||||
},
|
||||
];
|
||||
|
||||
enum Event {
|
||||
BAD_ROUTE = "pj:bad-route",
|
||||
}
|
||||
|
||||
window.customElements.define("pj-app", class extends HTMLElement {
|
||||
window.customElements.define(
|
||||
"pj-app",
|
||||
class extends HTMLElement {
|
||||
private _content: DocumentFragment;
|
||||
$nav: Nav;
|
||||
$layout: Layout;
|
||||
$mainMenuButton: HTMLAnchorElement;
|
||||
$pages: Pages;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this._content = template.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
|
||||
this.$layout =
|
||||
this._content.getElementById("layout") as
|
||||
Layout;
|
||||
this.$nav =
|
||||
this._content.getElementById("nav") as
|
||||
Nav;
|
||||
this.$mainMenuButton =
|
||||
this._content.getElementById("main-menu") as
|
||||
HTMLAnchorElement;
|
||||
this.$pages =
|
||||
this._content.getElementById("pages") as
|
||||
Pages;
|
||||
|
||||
// (this._content.getElementById("logo") as HTMLElement).innerHTML = logo;
|
||||
this._content = template.content.cloneNode(
|
||||
true
|
||||
) as DocumentFragment;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot?.appendChild(this._content);
|
||||
|
||||
this.bind();
|
||||
|
||||
this.route(window.location.pathname);
|
||||
}
|
||||
|
||||
bind() {
|
||||
this.addEventListener(Event.BAD_ROUTE, e => {
|
||||
this.$pages.select((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
this.$mainMenuButton.addEventListener("click", e => {
|
||||
this.$layout.openDrawer("right");
|
||||
});
|
||||
|
||||
this.initNavigation(navigationItems);
|
||||
this.initRouting();
|
||||
this.initMedTraceRequirement();
|
||||
}
|
||||
|
||||
initMedTraceRequirement() {
|
||||
const cards = this.shadowRoot!.querySelectorAll("my-card");
|
||||
const $myButton = this.shadowRoot!.getElementById("my-button");
|
||||
|
||||
$myButton?.addEventListener("click", () => {
|
||||
const msg = "button clicked";
|
||||
|
||||
for (const $card of cards) {
|
||||
$card.setAttribute(
|
||||
"message",
|
||||
$card.getAttribute("message") === msg
|
||||
? ""
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initRouting() {
|
||||
window.addEventListener("popstate", e => {
|
||||
// console.log("Dapp/top-level: popstate", window.location.href);
|
||||
this.route(window.location.pathname);
|
||||
this.$layout.closeDrawer("right");
|
||||
});
|
||||
}
|
||||
|
||||
navRelocate(
|
||||
screenTallEnoughForFooterNav: boolean,
|
||||
screenWideEnoughForDrawer: boolean,
|
||||
) {
|
||||
if (screenWideEnoughForDrawer) {
|
||||
this.$nav.slot = "right-drawer";
|
||||
this.$nav.setAttribute("display-mode", "stack");
|
||||
} else if (screenTallEnoughForFooterNav) {
|
||||
this.$nav.slot = "footer";
|
||||
this.$nav.setAttribute("display-mode", "flex");
|
||||
} else {
|
||||
this.$nav.slot = "right-drawer";
|
||||
this.$nav.setAttribute("display-mode", "stack");
|
||||
}
|
||||
|
||||
if (this.$nav.slot === "right-drawer") {
|
||||
this.$mainMenuButton.classList.remove("hidden");
|
||||
} else {
|
||||
this.$mainMenuButton.classList.add("hidden");
|
||||
this.$layout.closeDrawer("right");
|
||||
}
|
||||
}
|
||||
|
||||
initNavigation(navigationItems: MenuItem[]) {
|
||||
navigationItems.forEach(item => this.$nav.addItem(item));
|
||||
this.$nav.init();
|
||||
|
||||
this.$layout.breakpointer.addHandler(this.navRelocate.bind(this));
|
||||
}
|
||||
|
||||
async route(path?: string) {
|
||||
if (!path) {
|
||||
return this.$pages.select("not-found");
|
||||
}
|
||||
|
||||
const route = new Route(`/:page`, path);
|
||||
const request = {
|
||||
// imbuer: this.imbuer,
|
||||
// accounts: this.accounts,
|
||||
// apiInfo: this.apiInfo,
|
||||
}
|
||||
|
||||
console.log(path, route);
|
||||
|
||||
if (!route.active) {
|
||||
/**
|
||||
* the path == `/app`, so we redirect to the default "app", which
|
||||
* is currently "/app/cards"
|
||||
*/
|
||||
utils.redirect(
|
||||
this.getAttribute("default-route") || "/cards/"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.data?.page) {
|
||||
this.$nav.selected = route.data.page;
|
||||
}
|
||||
|
||||
switch (route.data?.page) {
|
||||
case "cards":
|
||||
this.$pages.select("cards");
|
||||
// (this.$pages.selected as Cards).route(route.tail, request);
|
||||
break;
|
||||
case "patients":
|
||||
this.$pages.select("patients");
|
||||
// (this.$pages.selected as Patients).route(route.tail, request);
|
||||
break;
|
||||
default:
|
||||
this.$pages.select("not-found");
|
||||
}
|
||||
}
|
||||
});
|
||||
241
src/ECGMonitor/ECGMonitor.ts
Normal file
241
src/ECGMonitor/ECGMonitor.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
const template = document.getElementById(
|
||||
"ecg-monitor-template"
|
||||
) as HTMLTemplateElement;
|
||||
|
||||
export default class ECGMonitor extends HTMLElement {
|
||||
#content: DocumentFragment;
|
||||
#$canvas: HTMLCanvasElement;
|
||||
#ctx: CanvasRenderingContext2D;
|
||||
#buffer: Float32Array;
|
||||
#heartRate?: number;
|
||||
#socket?: WebSocket;
|
||||
#readIndex: number;
|
||||
#writeIndex: number;
|
||||
#bufferSize: number = 1 << 10;
|
||||
#srate: number = 125;
|
||||
#lastUpdateTimestamp: number = 0;
|
||||
#ecgColor: number = 0x39ff14bf;
|
||||
#gridColor = "#aa55007f";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.#content = template.content.cloneNode(
|
||||
true
|
||||
) as DocumentFragment;
|
||||
this.#$canvas = this.#content.querySelector(
|
||||
"#ecg-canvas"
|
||||
) as HTMLCanvasElement;
|
||||
this.#ctx = this.#$canvas.getContext(
|
||||
"2d"
|
||||
) as CanvasRenderingContext2D;
|
||||
this.#buffer = new Float32Array(this.#bufferSize);
|
||||
}
|
||||
|
||||
connectWebSocket () {
|
||||
this.#socket = new WebSocket(`ws://localhost:7890/ecg`);
|
||||
this.#socket.onmessage = ({ data: y }) => {
|
||||
const val = Number.parseFloat(y);
|
||||
|
||||
if (Number.isNaN(val)) {
|
||||
try {
|
||||
const { srate } = JSON.parse(y);
|
||||
if (srate !== void 0) {
|
||||
this.#srate = srate;
|
||||
console.log(
|
||||
`\`srate\` changed to ${srate}`
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
} else {
|
||||
// `data` is a raw sample
|
||||
this.writeSample(val);
|
||||
}
|
||||
};
|
||||
|
||||
this.#socket.onclose = () => {
|
||||
console.warn(
|
||||
"WebSocket closed, attempting to reconnect..."
|
||||
);
|
||||
setTimeout(() => this.connectWebSocket(), 1000);
|
||||
};
|
||||
|
||||
this.#socket.onerror = (err) => {
|
||||
console.error("WebSocket error:", err);
|
||||
this.#socket?.close();
|
||||
};
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot!.appendChild(this.#content);
|
||||
this.#buffer = new Float32Array(this.#bufferSize);
|
||||
this.#readIndex = 0;
|
||||
this.#writeIndex = 0;
|
||||
this.connectWebSocket();
|
||||
this.resizeCanvas();
|
||||
this.draw();
|
||||
window.onresize = () => this.resizeCanvas();
|
||||
|
||||
(window as any).ecg = this;
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
const $canvas = this.#$canvas;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
// Set canvas size to match its display size * device pixel ratio
|
||||
const rect = $canvas.getBoundingClientRect();
|
||||
$canvas.width = rect.width * dpr;
|
||||
$canvas.height = rect.height * dpr;
|
||||
// Scale context so drawing is sharp
|
||||
this.#ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform
|
||||
this.#ctx.scale(dpr, dpr);
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["heart-rate"];
|
||||
}
|
||||
|
||||
attributeChangedCallback(
|
||||
name: string,
|
||||
_old: string,
|
||||
value: string
|
||||
) {
|
||||
if (name === "heart-rate") {
|
||||
setTimeout(
|
||||
() => (this.heartRate = parseInt(value, 10)),
|
||||
100
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
set heartRate(val: number | undefined) {
|
||||
this.#heartRate = val;
|
||||
this.#socket?.send(
|
||||
JSON.stringify({
|
||||
heartRate: this.#heartRate ?? 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get heartRate() {
|
||||
return this.#heartRate;
|
||||
}
|
||||
|
||||
writeSample(val: number) {
|
||||
this.#buffer[this.#writeIndex] = val;
|
||||
this.#writeIndex =
|
||||
(this.#writeIndex + 1) % this.#bufferSize;
|
||||
|
||||
// Handle buffer overflow; overwrite oldest data
|
||||
if (this.#writeIndex === this.#readIndex) {
|
||||
this.#readIndex =
|
||||
(this.#readIndex + 1) % this.#bufferSize;
|
||||
}
|
||||
}
|
||||
|
||||
readBatch(batchSize: number): number[] {
|
||||
const batch: number[] = [];
|
||||
let tempReadIndex = this.#readIndex;
|
||||
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
if (tempReadIndex === this.#writeIndex) {
|
||||
// No new data
|
||||
break;
|
||||
}
|
||||
batch.push(this.#buffer[tempReadIndex]);
|
||||
tempReadIndex =
|
||||
(tempReadIndex + 1) % this.#bufferSize;
|
||||
}
|
||||
|
||||
this.#readIndex =
|
||||
(this.#readIndex + 1) % this.#bufferSize;
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
drawECGWaveForm() {
|
||||
const ctx = this.#ctx;
|
||||
const $canvas = this.#$canvas;
|
||||
const canvasHeight = $canvas.height;
|
||||
const data = this.readBatch($canvas.width);
|
||||
const random = Math.random();
|
||||
|
||||
ctx.strokeStyle =
|
||||
"#" +
|
||||
(this.#ecgColor + random * 0x3f)
|
||||
.toString(16)
|
||||
.split(".")[0];
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let x = 0; x < data.length; x++) {
|
||||
let y =
|
||||
$canvas.height * (1 / 2) -
|
||||
(data[x] + random * 0.0075) *
|
||||
(canvasHeight / 10);
|
||||
|
||||
if (x === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
drawGridLines() {
|
||||
const ctx = this.#ctx;
|
||||
const $canvas = this.#$canvas;
|
||||
|
||||
ctx.strokeStyle = this.#gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x < $canvas.width; x += 20) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, $canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y < $canvas.height; y += 20) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo($canvas.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Draw the ECG waveform on a grid, synchronized to the sampling rate.
|
||||
*
|
||||
* `1/srate` is the fraction of a second corresponding to one sample.
|
||||
* We convert that to milliseconds and compare to the last update, so
|
||||
* that we draw at regular intervals corresponding to `this.#srate`.
|
||||
*/
|
||||
draw(ts?: DOMHighResTimeStamp) {
|
||||
const now = ts ?? performance.now();
|
||||
|
||||
if (
|
||||
now - this.#lastUpdateTimestamp >=
|
||||
(1 / this.#srate) * 1000
|
||||
) {
|
||||
this.#lastUpdateTimestamp = now;
|
||||
|
||||
const ctx = this.#ctx;
|
||||
const $canvas = this.#$canvas;
|
||||
|
||||
ctx.clearRect(0, 0, $canvas.width, $canvas.height);
|
||||
this.drawGridLines();
|
||||
this.drawECGWaveForm();
|
||||
}
|
||||
|
||||
requestAnimationFrame((ts: DOMHighResTimeStamp) =>
|
||||
this.draw(ts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define("ecg-monitor", ECGMonitor);
|
||||
|
||||
export {};
|
||||
25
src/ECGMonitor/template.html
Normal file
25
src/ECGMonitor/template.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<template id="ecg-monitor-template">
|
||||
<style>
|
||||
.container {
|
||||
background: #111;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, #201717 0%, #000);
|
||||
box-shadow: 0 0 3px #555;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 1024px;
|
||||
max-height: 600px;
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<canvas id="ecg-canvas"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template id="ecg-ticker-template">
|
||||
<style>
|
||||
#canvas-container {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
</style>
|
||||
<div id="canvas-container"></div>
|
||||
</template>
|
||||
@@ -1,101 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import {OrbitControls} from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
|
||||
const template = document.getElementById("ecg-ticker-template") as
|
||||
HTMLTemplateElement;
|
||||
|
||||
enum Attributes {
|
||||
HEART_RATE = "heart-rate",
|
||||
}
|
||||
|
||||
export default class ECGTicker extends HTMLElement {
|
||||
static observedAttributes = [Attributes.HEART_RATE];
|
||||
|
||||
private _content: DocumentFragment;
|
||||
$canvasContainer: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attachShadow({mode: "open"});
|
||||
this._content = template.content.cloneNode(true) as DocumentFragment;
|
||||
this.$canvasContainer = this._content.getElementById("canvas-container")!;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this.shadowRoot) {
|
||||
this.shadowRoot.appendChild(this._content);
|
||||
} else {
|
||||
console.warn("No shadowRoot detected.");
|
||||
}
|
||||
|
||||
this.initECGAnimation();
|
||||
}
|
||||
|
||||
initECGAnimation() {
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
|
||||
renderer.setSize(200, 300);
|
||||
this.$canvasContainer.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const axesHelper = new THREE.AxesHelper(5);
|
||||
scene.add(axesHelper);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
75,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
1000
|
||||
);
|
||||
const orbit = new OrbitControls(camera, renderer.domElement);
|
||||
|
||||
camera.position.set(1, 2, 5);
|
||||
orbit.update();
|
||||
|
||||
const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
|
||||
const boxMaterial = new THREE.MeshBasicMaterial({color: 0xffff00});
|
||||
const box = new THREE.Mesh(boxGeometry, boxMaterial);
|
||||
scene.add(box);
|
||||
|
||||
let heart_amp = 0.0;
|
||||
|
||||
function animate(x) {
|
||||
let sr = 120;
|
||||
let freq = 20;
|
||||
|
||||
box.position.x = 0.01 * Math.sin(2 * Math.PI * freq * (x / sr));
|
||||
box.position.y = 0.02 * Math.sin(2 * Math.PI * (freq/2) * (x / sr));
|
||||
// box.position.z = 0.05 * Math.sin(2 * Math.PI * (freq/3) * (x / sr));
|
||||
box.position.z = heart_amp * 1;
|
||||
box.rotation.set(x/2000, x/2000, 0);
|
||||
// camera.position.x = 1 * Math.sin(2 * Math.PI * (freq/6) * (x / sr));
|
||||
// camera.position.y = 2 * Math.sin(2 * Math.PI * (freq/11) * (x / sr));
|
||||
// camera.position.z = 1 + Math.abs(15 * Math.sin(2 * Math.PI * (freq/75) * (x / sr)));
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
// const socket = new WebSocket(`${location.protocol === "https" ? "wss" : "ws"}://${location.hostname}:7890/ecg`);
|
||||
const socket = new WebSocket(`ws://localhost:7890/ecg`);
|
||||
socket.onmessage = (({data}) => {
|
||||
heart_amp = data;
|
||||
console.log(heart_amp);
|
||||
|
||||
});
|
||||
|
||||
renderer.setAnimationLoop(animate);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _prev: string, curr: string) {
|
||||
if (Attributes.HEART_RATE === name) {
|
||||
// send message via websocket to alter heart rate in real time
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.customElements.define("ecg-ticker", ECGTicker);
|
||||
|
||||
export {};
|
||||
@@ -1,242 +0,0 @@
|
||||
<template id="layout-template">
|
||||
<style>
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-content: baseline;
|
||||
height: 100%;
|
||||
color: var(--theme-ink-color);
|
||||
flex-wrap: wrap;
|
||||
--pj-layout-header-height: 2.5rem;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
min-height: calc(100vh - 55px);
|
||||
--pj-layout-padding: 30px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
--pj-layout-max-screen: 1366px;
|
||||
}
|
||||
|
||||
.padded {
|
||||
transition: padding 200ms ease-in-out;
|
||||
padding: var(--pj-layout-padding);
|
||||
}
|
||||
#header-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
top: 0;
|
||||
background: var(--theme-background-dark);
|
||||
transition:
|
||||
background 200ms ease-in-out,
|
||||
box-shadow 200ms ease-in-out,
|
||||
padding 200ms ease-in-out;
|
||||
}
|
||||
#header-wrapper.hidden {
|
||||
z-index: 1;
|
||||
position: sticky;
|
||||
transform: translateY(-125px);
|
||||
}
|
||||
#header-wrapper.hidden-transition {
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
#header-wrapper.sticky {
|
||||
box-shadow: 0 0 5px #aaa;
|
||||
z-index: 1;
|
||||
position: sticky;
|
||||
background: var(--theme-background-top-bar);
|
||||
transform: translateY(0px);
|
||||
transition: transform 150ms ease-in-out;
|
||||
}
|
||||
#footer {
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
#footer.hidden {
|
||||
box-shadow: 0 0 5px #aaa;
|
||||
transform: translateY(75px);
|
||||
}
|
||||
#footer.hidden-transition {
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
#footer.sticky {
|
||||
box-shadow: 0 0 5px #aaa;
|
||||
transform: translateY(0px);
|
||||
transition: transform 150ms ease-in-out;
|
||||
}
|
||||
#main-header {
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.15rem;
|
||||
}
|
||||
#main-header h1 {
|
||||
font-size: 1.15rem;
|
||||
margin: 0;
|
||||
}
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
}
|
||||
.main-title {
|
||||
display: block;
|
||||
margin: 0 0 0 .5rem;
|
||||
margin: 0;
|
||||
width: 160px;
|
||||
height: 43px;
|
||||
}
|
||||
#main-content {
|
||||
overflow: auto;
|
||||
width: 100vw;
|
||||
min-height: inherit;
|
||||
}
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
width: 80vw;
|
||||
overflow: auto;
|
||||
transition: transform 200ms cubic-bezier(1,0,0,1);
|
||||
transition: all 200ms ease-in-out;
|
||||
}
|
||||
#left-drawer.open, #right-drawer.open {
|
||||
transform: translateX(0);
|
||||
z-index: 2;
|
||||
}
|
||||
#left-drawer {
|
||||
left: 0;
|
||||
background: rgba(90,90,90,.90);
|
||||
transform: translateX(-100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
z-index: 1;
|
||||
}
|
||||
#right-drawer {
|
||||
right: 0;
|
||||
background: var(--theme-background-dark);
|
||||
transform: translateX(100%);
|
||||
z-index: 1;
|
||||
}
|
||||
.modal {
|
||||
width: 100vw;
|
||||
max-width: 100%;
|
||||
transform: translateX(-100vw);
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(200,200,200,.10);
|
||||
opacity: 0;
|
||||
}
|
||||
.modal.show {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
#loading-modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
transition: opacity 1000ms ease-in;
|
||||
}
|
||||
#loading-modal svg {
|
||||
max-width: 10em;
|
||||
}
|
||||
#context-menu {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 55%;
|
||||
max-width: 200px;
|
||||
height: 35px;
|
||||
}
|
||||
#context-menu.hidden {
|
||||
display: none;
|
||||
}
|
||||
.context-menu-spacer {
|
||||
border-right: 1px dotted #ccc;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
@media screen
|
||||
/* and (min-width: 500px) */
|
||||
and (max-width: 500px) {
|
||||
.padded {
|
||||
padding: calc(var(--pj-layout-padding) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Left hand side menu is now fixed in place and hamburger goes away.
|
||||
*/
|
||||
@media screen
|
||||
and (min-width: 500px) {
|
||||
|
||||
:host {
|
||||
/* min-width: 700px; */
|
||||
max-width: var(--pj-layout-max-screen);
|
||||
}
|
||||
#main-content {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
slot[name="main"] {
|
||||
width: 1%;
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1366px) {
|
||||
#header-wrapper.sticky {
|
||||
box-shadow: none;
|
||||
z-index: auto;
|
||||
position: initial;
|
||||
background: inherit;
|
||||
transform: translateY(0px);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 700px) {
|
||||
.drawer {
|
||||
width: 30vw;
|
||||
min-width: 300px;
|
||||
max-width: 555px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<header id="header-wrapper" class="padded">
|
||||
<div id="main-header">
|
||||
<h1 class="main-title">
|
||||
<slot name="main-title"></slot>
|
||||
</h1>
|
||||
<div class="spacer"></div>
|
||||
<div id="context-menu">
|
||||
<slot name="context-menu"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="modal" id="modal"></div>
|
||||
<div class="modal" id="loading-modal">
|
||||
<!-- SVG spinner -->
|
||||
</div>
|
||||
|
||||
<div class="drawer" id="left-drawer">
|
||||
<slot name="left-drawer"></slot>
|
||||
</div>
|
||||
<div class="drawer" id="right-drawer">
|
||||
<slot name="right-drawer"></slot>
|
||||
</div>
|
||||
|
||||
<main id="main-content" class="padded">
|
||||
<slot name="main"></slot>
|
||||
</main>
|
||||
|
||||
<div id="footer" class="padded">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,222 +0,0 @@
|
||||
import { Mutex, debounce } from "../utils.js";
|
||||
|
||||
// import materialIcons from "../html/material-icons-link.html";
|
||||
// import globe from "../../assets/world.svg";
|
||||
|
||||
export type Breakpoints = {
|
||||
minHeight: number;
|
||||
minWidth: number;
|
||||
}
|
||||
|
||||
class Breakpointer {
|
||||
heightMatcher: MediaQueryList;
|
||||
widthMatcher: MediaQueryList;
|
||||
|
||||
constructor(breakpoints: Breakpoints) {
|
||||
this.heightMatcher = matchMedia(`(min-height: ${breakpoints.minHeight}px)`);
|
||||
this.widthMatcher = matchMedia(`(min-width: ${breakpoints.minWidth}px)`);
|
||||
}
|
||||
|
||||
addHandler(handler: (h: boolean, w: boolean) => void): void {
|
||||
const job = () => handler(
|
||||
this.heightMatcher.matches,
|
||||
this.widthMatcher.matches,
|
||||
);
|
||||
|
||||
this.heightMatcher.addEventListener("change", job);
|
||||
this.widthMatcher.addEventListener("change", job);
|
||||
job();
|
||||
}
|
||||
}
|
||||
|
||||
const template = document.getElementById("layout-template") as
|
||||
HTMLTemplateElement;
|
||||
|
||||
const DIVIDER = Symbol();
|
||||
export {
|
||||
DIVIDER
|
||||
}
|
||||
|
||||
type DrawerSide = "left"|"right";
|
||||
type ClasslistMethod = "toggle"|"add"|"remove";
|
||||
|
||||
|
||||
export default class Layout extends HTMLElement {
|
||||
|
||||
private _content: DocumentFragment;
|
||||
|
||||
$mainTitle: HTMLElement;
|
||||
$contextMenu: HTMLElement;
|
||||
$loadingModal: HTMLElement;
|
||||
$modal: HTMLElement;
|
||||
|
||||
$header: HTMLElement;
|
||||
$footer: HTMLElement;
|
||||
|
||||
$drawers: {
|
||||
left: HTMLElement;
|
||||
right: HTMLElement;
|
||||
}
|
||||
breakpointer: Breakpointer;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({mode:"open"});
|
||||
this._content =
|
||||
template.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
|
||||
this.$mainTitle =
|
||||
this._content.getElementById("main-title") as
|
||||
HTMLElement;
|
||||
|
||||
this.$contextMenu =
|
||||
this._content.getElementById("context-menu") as
|
||||
HTMLElement;
|
||||
|
||||
this.$loadingModal =
|
||||
this._content.getElementById("loading-modal") as
|
||||
HTMLElement;
|
||||
|
||||
this.$header =
|
||||
this._content.getElementById("header-wrapper") as
|
||||
HTMLElement;
|
||||
this.$footer =
|
||||
this._content.getElementById("footer") as
|
||||
HTMLElement;
|
||||
|
||||
this.$drawers = {
|
||||
left: this._content.getElementById("left-drawer") as
|
||||
HTMLElement,
|
||||
right: this._content.getElementById("right-drawer") as
|
||||
HTMLElement,
|
||||
};
|
||||
|
||||
this.$modal =
|
||||
this._content.getElementById("modal") as
|
||||
HTMLElement;
|
||||
|
||||
const drawerWidth = 500;
|
||||
const bottomNavHeight = 600;
|
||||
this.breakpointer = new Breakpointer({
|
||||
minHeight: bottomNavHeight,
|
||||
minWidth: drawerWidth,
|
||||
});
|
||||
|
||||
this.bindDrawerListeners();
|
||||
this.bindAnimatedHeader();
|
||||
this.bindLoading();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot?.appendChild(this._content);
|
||||
this.breakpointer.addHandler(this.handleBreakpoints.bind(this));
|
||||
}
|
||||
|
||||
handleBreakpoints(minHeight: boolean, minWidth: boolean) {
|
||||
this.$footer.classList[
|
||||
minWidth ? "add" : "remove"
|
||||
]("padded");
|
||||
|
||||
this.$contextMenu.classList[
|
||||
minWidth ? "remove" : "add"
|
||||
]("hidden");
|
||||
}
|
||||
|
||||
bindLoading() {
|
||||
let loadingCount = 0;
|
||||
const mutex = new Mutex;
|
||||
|
||||
this.addEventListener("loading-start", (async (e) => {
|
||||
const unlock = await mutex.lock();
|
||||
loadingCount++;
|
||||
this.$loadingModal.classList.add("show");
|
||||
unlock();
|
||||
}));
|
||||
|
||||
this.addEventListener("loading-end", (async (e) => {
|
||||
const unlock = await mutex.lock();
|
||||
if (--loadingCount <= 0) {
|
||||
this.$loadingModal.classList.remove("show");
|
||||
loadingCount = 0;
|
||||
}
|
||||
unlock();
|
||||
}));
|
||||
}
|
||||
|
||||
bindAnimatedHeader() {
|
||||
let lastWinTop = 0;
|
||||
window.addEventListener("scroll", debounce(() => {
|
||||
const headerHeight = this.$header.offsetHeight;
|
||||
const classList = this.$header.classList;
|
||||
const winTop = window.scrollY;
|
||||
if (winTop < 1) {
|
||||
classList.remove("sticky");
|
||||
classList.remove("hidden");
|
||||
classList.remove("hidden-transition");
|
||||
} else if (winTop < lastWinTop) {
|
||||
classList.add("sticky");
|
||||
classList.remove("hidden");
|
||||
if (lastWinTop > headerHeight)
|
||||
classList.add("hidden-transition");
|
||||
} else if (winTop > headerHeight && winTop > lastWinTop) {
|
||||
classList.remove("sticky");
|
||||
classList.add("hidden");
|
||||
}
|
||||
|
||||
this.$footer.className = "";
|
||||
classList.forEach(x => {
|
||||
if (x !== "padded")
|
||||
this.$footer.classList.add(x)
|
||||
});
|
||||
|
||||
lastWinTop = winTop;
|
||||
}, 10));
|
||||
}
|
||||
|
||||
bindDrawerListeners() {
|
||||
this.$drawers.left.addEventListener("click", e => this.handleCloseClick(e, "left"));
|
||||
this.$modal.addEventListener("click", e => this.closeDrawers());
|
||||
window.addEventListener("keyup", e => {
|
||||
if (e.key === "Escape")
|
||||
this.closeDrawers();
|
||||
});
|
||||
this.addEventListener("layout-drawer-toggle", (e: Event) => {
|
||||
const {which, action} = (e as CustomEvent).detail;
|
||||
this.toggleDrawer(which, action);
|
||||
});
|
||||
}
|
||||
|
||||
closeDrawers() {
|
||||
this.closeDrawer("left");
|
||||
this.closeDrawer("right");
|
||||
}
|
||||
toggleDrawer(which: DrawerSide, action: ClasslistMethod = "toggle") {
|
||||
this.$drawers[which].classList[action]("open");
|
||||
this.$modal.classList[action]("show");
|
||||
document.documentElement.classList[action]("modal");
|
||||
}
|
||||
closeDrawer(which: DrawerSide) { this.toggleDrawer(which, "remove"); }
|
||||
openDrawer(which: DrawerSide) { this.toggleDrawer(which, "add"); }
|
||||
|
||||
handleCloseClick(e: Event, which: DrawerSide = "left", force?: boolean) {
|
||||
const $target = e.target as Element;
|
||||
if (!force && $target.slot === `${which}-drawer`)
|
||||
return;
|
||||
this.closeDrawer(which);
|
||||
}
|
||||
|
||||
set title(src: string) {
|
||||
this.$mainTitle.innerHTML = src;
|
||||
}
|
||||
|
||||
/**
|
||||
* `src` here is HTML that might be an SVG or an `img` tag with `src`
|
||||
* attribute set appropriately, etc.
|
||||
*/
|
||||
set loadingModalImg(src: string) {
|
||||
this.$loadingModal.innerHTML = src;
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define("pj-layout", Layout);
|
||||
@@ -1,46 +0,0 @@
|
||||
<!-- the template for the custom component -->
|
||||
<template id="my-card-template">
|
||||
<!-- shadow DOM styles -->
|
||||
<style>
|
||||
:host {
|
||||
box-shadow: 0px 0px 5px var(--theme-primary-pale);
|
||||
display: block;
|
||||
padding: 0 20px 10px 20px;
|
||||
transition: var(--base-transition);
|
||||
font-family: sans-serif;
|
||||
min-height: 222px;
|
||||
/* border-radius: 3px; */
|
||||
}
|
||||
:host(:hover) {
|
||||
box-shadow: 0px 0px 20px var(--theme-primary);
|
||||
}
|
||||
header {
|
||||
/* using the CSS var assigned from the "light" DOM; default
|
||||
green is never displayed because the var is initialized
|
||||
above. */
|
||||
color: var(--my-card-header-color, green);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
#message {
|
||||
margin: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<header class="">
|
||||
<h1>
|
||||
<slot name="headline">
|
||||
Default headline
|
||||
</slot>
|
||||
</h1>
|
||||
</header>
|
||||
<!-- this could have been a slot named "main" like
|
||||
`<slot name="main"></slot>`
|
||||
but that makes it harder to use in the HTML below. -->
|
||||
<section id="main">
|
||||
<slot></slot>
|
||||
</section>
|
||||
<div id="message"></div>
|
||||
</template>
|
||||
@@ -1,55 +0,0 @@
|
||||
import ECGTicker from "../ECGTicker/ECGTicker.js";
|
||||
|
||||
console.log(ECGTicker);
|
||||
|
||||
/**
|
||||
* This could also be imported as a string or written here and attached to the
|
||||
* `document.body` as a `template` element, like
|
||||
*
|
||||
* const template = document.createElement("template");
|
||||
* template.innerHTML = `<style>...</style><div>...</div>`;
|
||||
* document.body.appendChild(template);
|
||||
*
|
||||
* which would only be run here once, similar in performance to parsing it in
|
||||
* index.html file.
|
||||
*/
|
||||
const template = document.getElementById("my-card-template") as
|
||||
HTMLTemplateElement;
|
||||
|
||||
enum Attributes {
|
||||
MESSAGE = "message",
|
||||
}
|
||||
|
||||
window.customElements.define("my-card", class extends HTMLElement {
|
||||
static observedAttributes = [Attributes.MESSAGE];
|
||||
|
||||
private _content: DocumentFragment;
|
||||
$message: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attachShadow({mode: "open"});
|
||||
this._content = template.content.cloneNode(true) as DocumentFragment;
|
||||
this.$message = this._content.getElementById("message")!;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this.shadowRoot) {
|
||||
this.shadowRoot.appendChild(this._content);
|
||||
} else {
|
||||
console.warn("No shadowRoot detected.");
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _prev: string, curr: string) {
|
||||
if (Attributes.MESSAGE === name) {
|
||||
while (this.$message.firstChild) {
|
||||
this.$message.removeChild(this.$message.lastChild!);
|
||||
}
|
||||
this.$message.appendChild(document.createTextNode(curr));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export {};
|
||||
126
src/Nav/Nav.html
126
src/Nav/Nav.html
@@ -1,126 +0,0 @@
|
||||
<template id="nav-template">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Material+Icons&display=block"
|
||||
rel="stylesheet">
|
||||
|
||||
<style>#nav.hidden {display: none;}</style>
|
||||
|
||||
<style id="stack">
|
||||
:host {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
}
|
||||
nav {
|
||||
color: var(--theme-ink-color);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 28px 0;
|
||||
font-size: 1.25rem;
|
||||
font-family: var(--theme-font-family);
|
||||
}
|
||||
nav > ul > li {
|
||||
transition: background 200ms ease-in-out;
|
||||
background: var(--theme-background-dark);
|
||||
flex-grow: 1;
|
||||
}
|
||||
nav > ul > li:hover {
|
||||
background: var(--theme-secondary-dark);
|
||||
transition: background 200ms ease-in-out;
|
||||
}
|
||||
nav > ul .selected, nav > ul .selected:hover {
|
||||
font-weight: bold;
|
||||
background-color: var(--theme-secondary-dark-hover); /* ??? */
|
||||
background: var(--theme-primary);
|
||||
}
|
||||
nav > ul .selected i ~ span::after {
|
||||
/*content: "*";*/
|
||||
}
|
||||
a {
|
||||
color: white;
|
||||
display: flex;
|
||||
text-decoration: none;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
i.material-icons {
|
||||
font-size: var(--icon-size);
|
||||
padding-top: 2px;
|
||||
}
|
||||
i ~ span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style id="flex">
|
||||
:host {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-shadow: 0 0 10px #777;
|
||||
}
|
||||
ul li {
|
||||
width: 25%;
|
||||
}
|
||||
li > a {
|
||||
color: var(--main-text-color);
|
||||
display: block;
|
||||
padding: 10px;
|
||||
}
|
||||
i.material-icons {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-size: 20pt;
|
||||
padding: 4px;
|
||||
}
|
||||
i ~ span {
|
||||
font-size: .8rem;
|
||||
text-align: center;
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
nav > ul {
|
||||
padding: 0;
|
||||
}
|
||||
nav > ul .selected i ~ span::after {
|
||||
content: none;
|
||||
}
|
||||
nav > ul .selected {
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style id="tab">
|
||||
nav > ul {
|
||||
background: #eee;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
nav > ul > li {
|
||||
max-width: 100px;
|
||||
flex-grow: 0;
|
||||
margin: 5px 10px 0 0;
|
||||
margin: 10px 10px 0 0;
|
||||
}
|
||||
ul > li a {
|
||||
background: #ccc;
|
||||
background: #eee;
|
||||
border-radius: 10% 10% 0 0;
|
||||
padding: 5px 0;
|
||||
}
|
||||
nav > ul .selected {
|
||||
box-shadow: 0 -2px 3px #aaa;
|
||||
}
|
||||
nav > ul .selected {
|
||||
font-weight: bold;
|
||||
background: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav id="nav" class="hidden">
|
||||
<ul id="menu"></ul>
|
||||
</nav>
|
||||
</template>
|
||||
188
src/Nav/Nav.ts
188
src/Nav/Nav.ts
@@ -1,188 +0,0 @@
|
||||
const template = document.getElementById("nav-template") as
|
||||
HTMLTemplateElement;
|
||||
|
||||
const navItemTemplate = document.createElement("template");
|
||||
navItemTemplate.innerHTML = `
|
||||
<li class="menu-item">
|
||||
<a>
|
||||
<i class="material-icons" aria-hidden="true"></i>
|
||||
<span></span>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
const displayModes = ["stack","flex","tab"] as const;
|
||||
type DisplayMode = typeof displayModes[number];
|
||||
export type MenuItem = {
|
||||
name: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
selected?: boolean;
|
||||
spa?: boolean;
|
||||
}
|
||||
|
||||
export default class Nav extends HTMLElement {
|
||||
|
||||
static observedAttributes = ["display-mode"];
|
||||
|
||||
_content: DocumentFragment;
|
||||
_selected?: MenuItem;
|
||||
_items: MenuItem[] = [];
|
||||
_styles: Record<DisplayMode,HTMLStyleElement> = {} as any;
|
||||
$nav: HTMLElement;
|
||||
$menu: HTMLUListElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({mode:"open"});
|
||||
this._content =
|
||||
template.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
|
||||
this.$nav =
|
||||
this._content.getElementById("nav") as
|
||||
HTMLElement;
|
||||
this.$menu =
|
||||
this._content.getElementById("menu") as
|
||||
HTMLUListElement;
|
||||
|
||||
displayModes.forEach(mode => {
|
||||
this._styles[mode] =
|
||||
this._content.getElementById(mode) as
|
||||
HTMLStyleElement;
|
||||
});
|
||||
|
||||
this._clearStyles();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this._content.hasChildNodes()) {
|
||||
this.shadowRoot?.appendChild(this._content);
|
||||
this.displayMode = "none";
|
||||
}
|
||||
}
|
||||
|
||||
private _clearStyles() {
|
||||
displayModes.forEach(x => {
|
||||
const $style = this._styles[x];
|
||||
// parentNode is shadowRoot or null
|
||||
$style.parentNode?.removeChild($style);
|
||||
});
|
||||
}
|
||||
|
||||
set displayMode(x: DisplayMode | "none") {
|
||||
if (!this.shadowRoot?.hasChildNodes()) {
|
||||
return;
|
||||
}
|
||||
const styles = document.createDocumentFragment();
|
||||
|
||||
this.$nav.classList.add("hidden");
|
||||
this._clearStyles();
|
||||
|
||||
switch(x) {
|
||||
case "none":
|
||||
return;
|
||||
case "tab":
|
||||
styles.appendChild(this._styles.tab);
|
||||
// no break
|
||||
case "flex":
|
||||
styles.prepend(this._styles.flex);
|
||||
// no break
|
||||
default:
|
||||
this.$nav.classList.remove("hidden");
|
||||
styles.prepend(this._styles.stack);
|
||||
this.shadowRoot?.insertBefore(styles, this.$nav);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
get selectedItem(): MenuItem | undefined {
|
||||
return this._selected;
|
||||
}
|
||||
set selected(name: string) {
|
||||
this.items.forEach(item => {
|
||||
if (item.name === name) {
|
||||
item.selected = true;
|
||||
this._selected = item;
|
||||
}
|
||||
else {
|
||||
item.selected = false;
|
||||
}
|
||||
});
|
||||
|
||||
const selected = this.selectedItem;
|
||||
if (selected) {
|
||||
this.updateSelected(selected);
|
||||
}
|
||||
}
|
||||
|
||||
updateSelected(item: MenuItem) {
|
||||
Array.from(this.$menu.querySelectorAll("a")).forEach($anchor => {
|
||||
$anchor.classList.remove("selected");
|
||||
if (
|
||||
$anchor.parentElement?.id ===
|
||||
`menu-item__${item.name}`
|
||||
) {
|
||||
$anchor.classList.add("selected");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get items() { return this._items }
|
||||
|
||||
addItem(item: MenuItem) {
|
||||
this._items.push(item);
|
||||
}
|
||||
|
||||
attributeChangedCallback(k: string, p: string, c: string) {
|
||||
switch (k) {
|
||||
case "display-mode":
|
||||
if (!displayModes.includes(c as any)) {
|
||||
throw new Error(
|
||||
`Attribute "display-mode" must be one of ${
|
||||
JSON.stringify(displayModes)
|
||||
}`
|
||||
);
|
||||
}
|
||||
this.displayMode = c as DisplayMode || "stack";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
while (this.$menu.firstElementChild)
|
||||
this.$menu.removeChild(this.$menu.lastElementChild as Node);
|
||||
|
||||
this.items.forEach(item => {
|
||||
const $item =
|
||||
navItemTemplate.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
const $li = $item.querySelector("li") as HTMLLIElement;
|
||||
const $anchor = $item.querySelector("a") as HTMLAnchorElement;
|
||||
const $icon = $item.querySelector("i") as HTMLSpanElement;
|
||||
const $label = $item.querySelector("span") as HTMLSpanElement;
|
||||
|
||||
$li.id = `menu-item__${item.name}`;
|
||||
$anchor.href = item.href;
|
||||
if (item.spa) {
|
||||
$anchor.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
window.history.pushState({}, "", $anchor.href);
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
this.selected = item.name;
|
||||
});
|
||||
}
|
||||
if (item.selected) {
|
||||
$anchor.classList.add("selected");
|
||||
}
|
||||
$anchor.title = item.label;
|
||||
$icon.innerText = item.icon;
|
||||
$label.innerText = item.label;
|
||||
|
||||
this.$menu.appendChild($item);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define("pj-nav", Nav);
|
||||
@@ -1,112 +0,0 @@
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = `<slot id="slot"></slot>`;
|
||||
|
||||
export default class Pages extends HTMLElement {
|
||||
|
||||
private _content: DocumentFragment;
|
||||
$slot: HTMLSlotElement;
|
||||
classForHidden: string = "hidden";
|
||||
|
||||
attrForSelected?: string;
|
||||
fallbackSelection?: string;
|
||||
selected?: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({mode: "open"});
|
||||
this._content =
|
||||
template.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
this.$slot =
|
||||
this._content.getElementById("slot") as
|
||||
HTMLSlotElement;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot?.appendChild(this._content);
|
||||
this.$slot.assignedElements().forEach($ => {
|
||||
// ($ as HTMLElement).style.display = "none";
|
||||
($ as HTMLElement).classList.add(this.classForHidden);
|
||||
});
|
||||
|
||||
const defaultSelection = this.getAttribute("default-selection");
|
||||
if (defaultSelection) {
|
||||
this.select(defaultSelection);
|
||||
}
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return [
|
||||
"attr-for-selected",
|
||||
"fallback-selection",
|
||||
"default-selection",
|
||||
"class-for-hidden",
|
||||
];
|
||||
}
|
||||
attributeChangedCallback(name: string, old: string, newv: string) {
|
||||
switch (name) {
|
||||
case "attr-for-selected":
|
||||
this.attrForSelected = newv;
|
||||
break;
|
||||
case "fallback-selection":
|
||||
this.fallbackSelection = newv;
|
||||
break;
|
||||
case "class-for-hidden":
|
||||
this.classForHidden = newv;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selectByAttribute(items: HTMLElement[], id: string) {
|
||||
let selected;
|
||||
|
||||
if (!this.attrForSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let item of items) {
|
||||
if (item.getAttribute(this.attrForSelected) === id) {
|
||||
// item.style.display = "";
|
||||
item.classList.remove(this.classForHidden);
|
||||
selected = item;
|
||||
} else {
|
||||
// item.style.display = "none";
|
||||
item.classList.add(this.classForHidden);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
select(id: string) {
|
||||
const items =
|
||||
this.$slot.assignedElements() as
|
||||
HTMLElement[];
|
||||
delete this.selected;
|
||||
|
||||
if (this.attrForSelected) {
|
||||
this.selected = this.selectByAttribute(items, id);
|
||||
if (!this.selected && this.fallbackSelection) {
|
||||
this.selected = this.selectByAttribute(items, this.fallbackSelection);
|
||||
}
|
||||
} else {
|
||||
// No `attrForSelected`; trying id == idx approach
|
||||
let idx = parseInt(id);
|
||||
|
||||
if (isNaN(idx)) {
|
||||
throw Error("No suitable `id` found.");
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (i === idx) {
|
||||
item.classList.remove(this.classForHidden);
|
||||
this.selected = item;
|
||||
}
|
||||
else {
|
||||
item.classList.add(this.classForHidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
window.customElements.define("pj-pages", Pages);
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* class designed to take a `path` and a `pattern` and answer the questions:
|
||||
*
|
||||
* Am I the active route? (Answered by `this.active === true`)
|
||||
* if active:
|
||||
* Do I have sub-routes? (Answered by `this.tail !== void 0`)
|
||||
*
|
||||
* These are recursive: `this.tail` provides the next `path`
|
||||
* to a possible subroute.
|
||||
*
|
||||
* e.g., ```javascript
|
||||
* // window.location.pathname === "/posts/123/edit";
|
||||
* const route = new Route("/posts/:id");
|
||||
* if (route.active) {
|
||||
* if (route.tail) {
|
||||
* // handle subroute
|
||||
* const id = router.data.id;
|
||||
* const subroute = new Route("/:action", route.tail);
|
||||
* switch (subroute.data?.action) {
|
||||
* // ... ?
|
||||
* }
|
||||
* } else {
|
||||
* // ... ?
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
export default class Route {
|
||||
private _pattern: string = "";
|
||||
private _path: string = "";
|
||||
private _data: Record<string,string> = {};
|
||||
private _active: boolean = false;
|
||||
private _tail: string = "";
|
||||
|
||||
/**
|
||||
* When constructed, the instance will always have a value for both `path`
|
||||
* and `pattern`, but we only want to `init` once, so we don't set
|
||||
* `this.path`, but `this._path` to ensure that we only `init` once.
|
||||
*
|
||||
* After this, the instance will re-`init` whenever `this.path` or
|
||||
* `this.pattern` are set extrinsically.
|
||||
*/
|
||||
constructor(pattern?: string, path?: string) {
|
||||
this._path = path !== void 0
|
||||
? path
|
||||
: window.location.pathname;
|
||||
|
||||
this.pattern = pattern || this._pattern;
|
||||
}
|
||||
|
||||
get pattern() {
|
||||
return this._pattern;
|
||||
}
|
||||
|
||||
set pattern(x: string) {
|
||||
this._pattern = x;
|
||||
this._init();
|
||||
}
|
||||
|
||||
get path() {
|
||||
return this._path;
|
||||
}
|
||||
|
||||
set path(x: string) {
|
||||
this._path = x;
|
||||
this._init();
|
||||
}
|
||||
|
||||
/**
|
||||
* `init`'s job is to determine whether this route is active, and to
|
||||
* construct the tail based on the given pattern.
|
||||
*
|
||||
* A pattern takes the form "/foo/:bar/baz" or "/:foo" or "/foo", etc.
|
||||
* The tail is the part of the path that begins after the first part of the
|
||||
* path that matches the `pattern`.
|
||||
*
|
||||
* If it's not a match, `active` is false, and the tail accessor will
|
||||
* provide `null`.
|
||||
*
|
||||
* `data` will be cached from the route as it's parsed, even if ultimately
|
||||
* there's no match, but the `get data` accessor will provide `null` if the
|
||||
* route isn't `active`, so those "cached" values will be inert.
|
||||
*/
|
||||
private _init() {
|
||||
const pathParts = this.path.split("/");
|
||||
const patternParts = this.pattern.split("/");
|
||||
|
||||
this._data = {};
|
||||
this._tail = "";
|
||||
|
||||
this._active = patternParts.every(
|
||||
(x, idx) => {
|
||||
if (x.length > 1 && x.startsWith(":")) {
|
||||
const part = pathParts[idx];
|
||||
return (
|
||||
this._data[x.substring(1)] =
|
||||
part && decodeURIComponent(part)
|
||||
);
|
||||
} else {
|
||||
return x === pathParts[idx];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this._tail = pathParts.slice(patternParts.length).join("/");
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
get data() {
|
||||
return this._active
|
||||
? this._data
|
||||
: null;
|
||||
}
|
||||
|
||||
get tail() {
|
||||
return !this._active
|
||||
? null
|
||||
: !this._tail.length
|
||||
? null
|
||||
: `/${this._tail}`;
|
||||
}
|
||||
}
|
||||
41
src/utils.ts
41
src/utils.ts
@@ -1,41 +0,0 @@
|
||||
export class Mutex {
|
||||
|
||||
private _current: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this._current = Promise.resolve();
|
||||
}
|
||||
|
||||
lock() {
|
||||
let _resolve: () => void;
|
||||
|
||||
const p = new Promise(resolve => {
|
||||
_resolve = () => resolve(void 0);
|
||||
});
|
||||
// Caller gets a promise that resolves when the current outstanding
|
||||
// lock resolves
|
||||
const rv = this._current.then(() => _resolve);
|
||||
// Don't allow the next request until the new promise is done
|
||||
this._current = p as Promise<void>;
|
||||
// Return the new promise
|
||||
return rv;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const debounce = (func: CallableFunction, ms: number) => {
|
||||
let timeout: any;
|
||||
return (...args: any[]) => {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, ms);
|
||||
};
|
||||
};
|
||||
|
||||
export const redirect = (path: string) => {
|
||||
window.history.pushState({}, "", path);
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
};
|
||||
Reference in New Issue
Block a user