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 $@)
|
mkdir -p $(dir $@)
|
||||||
npx swc $< -o $@
|
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: export TEMPLATES = $(shell cat $(HTML_SRCS))
|
||||||
$(BUILD_DIR)/index.html: $(HTML_SRCS) $(BUILD_DIR) ./index.template.html
|
$(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' > $@
|
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
|
serve: all
|
||||||
npx serve -s $(BUILD_DIR)
|
npx serve -s $(BUILD_DIR)
|
||||||
|
|
||||||
clean:
|
dev:
|
||||||
rm -rf $(BUILD_DIR) ./node_modules
|
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 content="width=device-width, initial-scale=1" name="viewport"/>
|
||||||
<meta name="theme-color" content="#1d1850"/>
|
<meta name="theme-color" content="#1d1850"/>
|
||||||
|
|
||||||
<title>MEDTRACE: Case - Frontend Developer</title>
|
<title>MEDTRACE</title>
|
||||||
|
|
||||||
<!-- ability to import three.js from cdn -->
|
<!-- ability to import three.js from cdn -->
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
@@ -25,19 +25,8 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
:root {
|
: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: #ff5a58;
|
||||||
--theme-error-hover: #ff7a78;
|
--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: #caa026;
|
||||||
--theme-primary-subdued: #846918;
|
--theme-primary-subdued: #846918;
|
||||||
@@ -45,7 +34,6 @@
|
|||||||
--theme-secondary: #251f65;
|
--theme-secondary: #251f65;
|
||||||
--theme-secondary-hover: #332a8b;
|
--theme-secondary-hover: #332a8b;
|
||||||
--theme-secondary-dark: #1d1850;
|
--theme-secondary-dark: #1d1850;
|
||||||
/* --theme-secondary-dark-hover: #121c7f; */
|
|
||||||
|
|
||||||
--theme-background: #120f32;
|
--theme-background: #120f32;
|
||||||
--theme-background-top-bar: #120f32fa;
|
--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": {
|
"devDependencies": {
|
||||||
"@swc/cli": "^0.3.12",
|
"@swc/cli": "^0.7.8",
|
||||||
"@swc/core": "^1.4.15",
|
"@swc/core": "^1.4.15",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
"serve": "^14.2.1"
|
"serve": "^14.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
178
src/App/App.html
178
src/App/App.html
@@ -1,179 +1,3 @@
|
|||||||
<template id="app-template">
|
<template id="app-template">
|
||||||
<link
|
<ecg-monitor name="patients"></ecg-monitor>
|
||||||
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>
|
|
||||||
</template>
|
</template>
|
||||||
190
src/App/App.ts
190
src/App/App.ts
@@ -1,192 +1,22 @@
|
|||||||
import "/Pages/Pages.js";
|
import "../ECGMonitor/ECGMonitor.js";
|
||||||
import Pages from "../Pages/Pages.js";
|
|
||||||
|
|
||||||
import "../Layout/Layout.js";
|
const template = document.getElementById("app-template") as HTMLTemplateElement;
|
||||||
import Layout from "../Layout/Layout.js";
|
|
||||||
|
|
||||||
import "../Nav/Nav.js";
|
window.customElements.define(
|
||||||
import Nav, { MenuItem } from "../Nav/Nav.js";
|
"pj-app",
|
||||||
|
class extends HTMLElement {
|
||||||
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 {
|
|
||||||
private _content: DocumentFragment;
|
private _content: DocumentFragment;
|
||||||
$nav: Nav;
|
|
||||||
$layout: Layout;
|
|
||||||
$mainMenuButton: HTMLAnchorElement;
|
|
||||||
$pages: Pages;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({mode: "open"});
|
this.attachShadow({ mode: "open" });
|
||||||
this._content = template.content.cloneNode(true) as
|
this._content = template.content.cloneNode(
|
||||||
DocumentFragment;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.shadowRoot?.appendChild(this._content);
|
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