clean up and edit readme

This commit is contained in:
2025-10-03 09:12:24 -04:00
parent 8bc482a236
commit 25dc849f18
22 changed files with 1721 additions and 2901 deletions

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 65,
"tabWidth": 4
}

View File

@@ -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

View File

@@ -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.)

Binary file not shown.

View File

@@ -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;

2656
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }
} }

View File

@@ -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">&#xe5d2;</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>

View File

@@ -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 {
private _content: DocumentFragment;
import Route from "../Route/Route.js"; constructor() {
import * as utils from "../utils.js"; super();
this.attachShadow({ mode: "open" });
import "../MyCard/MyCard.js"; this._content = template.content.cloneNode(
true
// import materialIcons from "../../material-icons-link.html"; ) as DocumentFragment;
// 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;
$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;
}
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") { connectedCallback() {
this.$mainMenuButton.classList.remove("hidden"); this.shadowRoot?.appendChild(this._content);
} 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");
}
}
});

View 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 {};

View 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>

View File

@@ -1,9 +0,0 @@
<template id="ecg-ticker-template">
<style>
#canvas-container {
height: 100vh;
width: 100vw;
}
</style>
<div id="canvas-container"></div>
</template>

View File

@@ -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 {};

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 {};

View File

@@ -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>

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}`;
}
}

View File

@@ -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"));
};