mirror of
https://github.com/asimonson1125/asimonson1125.github.io.git
synced 2026-09-22 11:35:33 -05:00
Compare commits
3 Commits
bbbf9795bc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dfa539783d | |||
| e76d7c499a | |||
| 062b76af09 |
@@ -1,7 +1,7 @@
|
|||||||
# Service Status Monitor
|
# Service Status Monitor
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Server-side monitoring system that checks the availability of asimonson.com services every 2 hours and provides uptime statistics.
|
Server-side monitoring system that checks the availability of asimonson.com services every 60 seconds and provides uptime statistics.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -9,20 +9,22 @@ Server-side monitoring system that checks the availability of asimonson.com serv
|
|||||||
|
|
||||||
#### 1. `monitor.py` - Service Monitoring Module
|
#### 1. `monitor.py` - Service Monitoring Module
|
||||||
- **Purpose**: Performs automated health checks on all services
|
- **Purpose**: Performs automated health checks on all services
|
||||||
- **Check Interval**: Every 2 hours (7200 seconds)
|
- **Check Interval**: Every 60 seconds
|
||||||
- **Services Monitored**:
|
- **Services Monitored**:
|
||||||
- asimonson.com
|
- asimonson.com
|
||||||
- files.asimonson.com
|
- files.asimonson.com
|
||||||
- git.asimonson.com
|
- git.asimonson.com
|
||||||
- pass.asimonson.com
|
|
||||||
- ssh.asimonson.com
|
|
||||||
|
|
||||||
**Features**:
|
**Features**:
|
||||||
- Tracks response times and HTTP status codes
|
- Tracks response times and HTTP status codes
|
||||||
- Calculates uptime percentages for multiple time periods (24h, 7d, 30d, all-time)
|
- Calculates uptime percentages for multiple time periods (24h, 7d, 30d, all-time)
|
||||||
- Persists data to PostgreSQL (`service_checks` table) via `DATABASE_URL` env var
|
- Persists data to PostgreSQL (`service_checks` table) via `DATABASE_URL` env var
|
||||||
- Gracefully degrades when no database is configured (local dev)
|
- Gracefully degrades when no database is configured (local dev)
|
||||||
- Runs in a background thread
|
- Runs in a background thread, with each check cycle guarded so a transient
|
||||||
|
failure (DB hiccup, network blip) logs and retries next interval instead of
|
||||||
|
killing the thread
|
||||||
|
- Flags the cached summary as `stale` if no check has landed in the last 5
|
||||||
|
intervals, so `/api/status` can't silently keep serving old data as fresh
|
||||||
|
|
||||||
#### 2. `app.py` - Flask Integration
|
#### 2. `app.py` - Flask Integration
|
||||||
- **New API Endpoint**: `/api/status`
|
- **New API Endpoint**: `/api/status`
|
||||||
@@ -39,13 +41,14 @@ Server-side monitoring system that checks the availability of asimonson.com serv
|
|||||||
- Displays response times and status codes
|
- Displays response times and status codes
|
||||||
- Shows total number of checks performed
|
- Shows total number of checks performed
|
||||||
- Manual refresh button
|
- Manual refresh button
|
||||||
- Auto-refreshes every 5 minutes
|
- Auto-refreshes every 60 seconds
|
||||||
|
|
||||||
#### 2. `static/js/status.js` - Frontend Logic
|
#### 2. `static/js/status.js` - Frontend Logic
|
||||||
- Fetches status data from `/api/status` API
|
- Fetches status data from `/api/status` API
|
||||||
- Updates UI with service status and uptime
|
- Updates UI with service status and uptime
|
||||||
- Handles error states gracefully
|
- Shows a dismissible-on-refresh notice banner when the response is a fetch
|
||||||
- Auto-refresh every 5 minutes
|
error, and a persistent notice when the backend flags the data as `stale`
|
||||||
|
- Auto-refresh every 60 seconds
|
||||||
|
|
||||||
#### 3. `static/css/App.css` - Styling
|
#### 3. `static/css/App.css` - Styling
|
||||||
- Color-coded status indicators:
|
- Color-coded status indicators:
|
||||||
@@ -99,7 +102,7 @@ cd src
|
|||||||
python3 app.py
|
python3 app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The monitoring will start automatically and perform an initial check immediately, then every 2 hours thereafter.
|
The monitoring will start automatically and perform an initial check immediately, then every 60 seconds thereafter.
|
||||||
|
|
||||||
### Accessing the Status Page
|
### Accessing the Status Page
|
||||||
Navigate to: `https://asimonson.com/status`
|
Navigate to: `https://asimonson.com/status`
|
||||||
@@ -115,7 +118,7 @@ To modify monitoring behavior, edit `src/monitor.py`:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# Change check interval (in seconds)
|
# Change check interval (in seconds)
|
||||||
CHECK_INTERVAL = 7200 # 2 hours
|
CHECK_INTERVAL = 60 # 1 minute
|
||||||
|
|
||||||
# Modify service list
|
# Modify service list
|
||||||
SERVICES = [
|
SERVICES = [
|
||||||
@@ -133,6 +136,6 @@ SERVICES = [
|
|||||||
|
|
||||||
- First deployment will show limited uptime data until enough checks accumulate
|
- First deployment will show limited uptime data until enough checks accumulate
|
||||||
- Historical data is preserved across server restarts (stored in PostgreSQL)
|
- Historical data is preserved across server restarts (stored in PostgreSQL)
|
||||||
- Page auto-refreshes every 5 minutes to show latest server data
|
- Page auto-refreshes every 60 seconds to show latest server data
|
||||||
- Manual refresh button available for immediate updates
|
- Manual refresh button available for immediate updates
|
||||||
- All checks performed server-side (no client-side CORS issues)
|
- All checks performed server-side (no client-side CORS issues)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ SERVICES = [
|
|||||||
CHECK_INTERVAL = 60 # seconds between checks
|
CHECK_INTERVAL = 60 # seconds between checks
|
||||||
RETENTION_DAYS = 90 # how long to keep records
|
RETENTION_DAYS = 90 # how long to keep records
|
||||||
CLEANUP_INTERVAL = 86400 # seconds between purge runs
|
CLEANUP_INTERVAL = 86400 # seconds between purge runs
|
||||||
|
STALE_AFTER = CHECK_INTERVAL * 5 # flag cached data as stale if no check has landed in this long
|
||||||
|
|
||||||
DATABASE_URL = os.environ.get('DATABASE_URL')
|
DATABASE_URL = os.environ.get('DATABASE_URL')
|
||||||
|
|
||||||
@@ -331,9 +332,21 @@ class ServiceMonitor:
|
|||||||
|
|
||||||
def get_status_summary(self):
|
def get_status_summary(self):
|
||||||
"""Return the cached status summary, refreshed once per check cycle
|
"""Return the cached status summary, refreshed once per check cycle
|
||||||
(see check_all_services), so this never itself touches the database."""
|
(see check_all_services), so this never itself touches the database.
|
||||||
|
|
||||||
|
Adds a `stale` flag computed against wall-clock time, since a summary
|
||||||
|
that stops updating (e.g. the monitor loop hit a persistent error) would
|
||||||
|
otherwise keep reporting its last-known last_check as if it were fresh.
|
||||||
|
"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return self._cached_summary
|
summary = dict(self._cached_summary)
|
||||||
|
|
||||||
|
stale = False
|
||||||
|
if summary.get('last_check'):
|
||||||
|
age = (datetime.now() - datetime.fromisoformat(summary['last_check'])).total_seconds()
|
||||||
|
stale = age > STALE_AFTER
|
||||||
|
summary['stale'] = stale
|
||||||
|
return summary
|
||||||
|
|
||||||
# ── Background loop ───────────────────────────────────────────
|
# ── Background loop ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -352,21 +365,37 @@ class ServiceMonitor:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def _run_check_cycle(self):
|
||||||
|
"""Run one check_all_services() call, catching any exception so a
|
||||||
|
transient failure (DB hiccup, network blip) can't kill the daemon
|
||||||
|
thread -- without this, the thread dies silently and /api/status
|
||||||
|
keeps serving an ever-staler cached summary with nothing to say so."""
|
||||||
|
try:
|
||||||
|
self.check_all_services()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[monitor] check cycle failed, will retry next interval: {e}")
|
||||||
|
|
||||||
|
def _run_cleanup(self):
|
||||||
|
try:
|
||||||
|
self._purge_old_records()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[monitor] cleanup failed, will retry next interval: {e}")
|
||||||
|
|
||||||
def start_monitoring(self):
|
def start_monitoring(self):
|
||||||
"""Start the background daemon thread for periodic checks and cleanup."""
|
"""Start the background daemon thread for periodic checks and cleanup."""
|
||||||
def monitor_loop():
|
def monitor_loop():
|
||||||
self.check_all_services()
|
self._run_check_cycle()
|
||||||
self._purge_old_records()
|
self._run_cleanup()
|
||||||
|
|
||||||
checks_since_cleanup = 0
|
checks_since_cleanup = 0
|
||||||
checks_per_cleanup = CLEANUP_INTERVAL // CHECK_INTERVAL
|
checks_per_cleanup = CLEANUP_INTERVAL // CHECK_INTERVAL
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
time.sleep(CHECK_INTERVAL)
|
time.sleep(CHECK_INTERVAL)
|
||||||
self.check_all_services()
|
self._run_check_cycle()
|
||||||
checks_since_cleanup += 1
|
checks_since_cleanup += 1
|
||||||
if checks_since_cleanup >= checks_per_cleanup:
|
if checks_since_cleanup >= checks_per_cleanup:
|
||||||
self._purge_old_records()
|
self._run_cleanup()
|
||||||
checks_since_cleanup = 0
|
checks_since_cleanup = 0
|
||||||
|
|
||||||
thread = Thread(target=monitor_loop, daemon=True)
|
thread = Thread(target=monitor_loop, daemon=True)
|
||||||
|
|||||||
@@ -1137,6 +1137,13 @@ figcaption {
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-notice {
|
||||||
|
color: var(--warn);
|
||||||
|
border: 1px solid var(--warn);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
Books
|
Books
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
@@ -1396,6 +1403,19 @@ figcaption {
|
|||||||
transition: width 0.2s ease, opacity 0.4s ease 0.1s;
|
transition: width 0.2s ease, opacity 0.4s ease 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-notice {
|
||||||
|
border-bottom: 1px solid var(--bad);
|
||||||
|
color: var(--bad);
|
||||||
|
font-family: var(--font-data);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-notice[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: -100%;
|
top: -100%;
|
||||||
|
|||||||
@@ -1,105 +1,521 @@
|
|||||||
const balls = [];
|
/**
|
||||||
const density = 0.00005;
|
* Ambient background: "Contour Field"
|
||||||
let screenWidth = window.innerWidth + 10;
|
*
|
||||||
let screenHeight = window.innerHeight + 10;
|
* Thin nested contour lines trace level curves of one continuous,
|
||||||
|
* slowly-drifting 2D value-noise surface, marched with a classic
|
||||||
|
* marching-squares pass on a coarse grid. Every line on screen is a
|
||||||
|
* cross-section of the SAME field at a fixed elevation, so neighboring
|
||||||
|
* contours are geometrically correlated (they nest, they never cross,
|
||||||
|
* they bulge and pinch together) -- it reads as one continuous terrain
|
||||||
|
* rather than a scatter of independent strokes. Nods to the site
|
||||||
|
* owner's geospatial / data-surface work (watershed + elevation
|
||||||
|
* surfaces) without being literal about it.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
const MAX_DIST = 150;
|
var container = document.getElementById("bg");
|
||||||
const MAX_DIST_SQUARED = MAX_DIST * MAX_DIST;
|
if (!container) return;
|
||||||
|
|
||||||
class Ball {
|
var canvas = document.createElement("canvas");
|
||||||
constructor(x, y, size, speed, angle) {
|
canvas.setAttribute("aria-hidden", "true");
|
||||||
this.x = x;
|
container.appendChild(canvas);
|
||||||
this.y = y;
|
var ctx = canvas.getContext("2d");
|
||||||
this.size = size;
|
if (!ctx) return;
|
||||||
this.speed = speed;
|
|
||||||
this.angle = angle;
|
var reduceMotion =
|
||||||
this.calcChange();
|
window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Value noise (hash lattice + smooth interpolation), no dependency.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
var PERM_SIZE = 256;
|
||||||
|
var PERM_MASK = PERM_SIZE - 1;
|
||||||
|
var perm = new Uint8Array(PERM_SIZE);
|
||||||
|
(function buildPermutation() {
|
||||||
|
var table = new Uint8Array(PERM_SIZE);
|
||||||
|
for (var i = 0; i < PERM_SIZE; i++) table[i] = i;
|
||||||
|
for (var j = PERM_SIZE - 1; j > 0; j--) {
|
||||||
|
var k = (Math.random() * (j + 1)) | 0;
|
||||||
|
var tmp = table[j];
|
||||||
|
table[j] = table[k];
|
||||||
|
table[k] = tmp;
|
||||||
|
}
|
||||||
|
perm.set(table);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function lattice(ix, iy) {
|
||||||
|
var a = perm[ix & PERM_MASK];
|
||||||
|
return perm[(a + iy) & PERM_MASK] / 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
calcChange() {
|
function fade(t) {
|
||||||
const radians = (this.angle * Math.PI) / 180
|
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||||
this.xSpeed = this.speed * Math.sin(radians);
|
|
||||||
this.ySpeed = this.speed * Math.cos(radians);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
function noise2D(x, y) {
|
||||||
this.x += this.xSpeed;
|
var xi = Math.floor(x);
|
||||||
this.y += this.ySpeed;
|
var yi = Math.floor(y);
|
||||||
if (this.x > screenWidth) {
|
var xf = x - xi;
|
||||||
this.x -= screenWidth;
|
var yf = y - yi;
|
||||||
} else if (this.x < 0) {
|
var u = fade(xf);
|
||||||
this.x += screenWidth;
|
var v = fade(yf);
|
||||||
}
|
var n00 = lattice(xi, yi);
|
||||||
if (this.y > screenHeight) {
|
var n10 = lattice(xi + 1, yi);
|
||||||
this.y -= screenHeight;
|
var n01 = lattice(xi, yi + 1);
|
||||||
} else if (this.y < 0) {
|
var n11 = lattice(xi + 1, yi + 1);
|
||||||
this.y += screenHeight;
|
var nx0 = n00 + (n10 - n00) * u;
|
||||||
}
|
var nx1 = n01 + (n11 - n01) * u;
|
||||||
this.draw();
|
return nx0 + (nx1 - nx0) * v;
|
||||||
}
|
}
|
||||||
|
|
||||||
draw() {
|
// Two-octave fractal sum, warped slightly so the field folds and
|
||||||
stroke(200, 100);
|
// breathes rather than just sliding sideways.
|
||||||
strokeWeight(2);
|
var FREQ_1 = 1 / 460; // px per full noise cycle, octave 1
|
||||||
fill(0);
|
var FREQ_2 = FREQ_1 * 2.3; // octave 2
|
||||||
ellipse(this.x, this.y, this.size, this.size);
|
function fieldValue(x, y, driftX, driftY, warpX, warpY) {
|
||||||
|
var n1 = noise2D((x + driftX) * FREQ_1, (y + driftY) * FREQ_1);
|
||||||
|
var n2 = noise2D(
|
||||||
|
(x + driftX + warpX) * FREQ_2,
|
||||||
|
(y + driftY + warpY) * FREQ_2
|
||||||
|
);
|
||||||
|
var fbm = n1 * 0.68 + n2 * 0.32; // ~[0,1]
|
||||||
|
return fbm * 2 - 1; // ~[-1,1], centered
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Marching squares
|
||||||
|
// Corner bit order: v0=top-left, v1=top-right, v2=bottom-right, v3=bottom-left
|
||||||
|
// Edge order: 0=top, 1=right, 2=bottom, 3=left
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
var CASE_EDGES = [
|
||||||
|
null, // 0
|
||||||
|
[0, 3], // 1
|
||||||
|
[0, 1], // 2
|
||||||
|
[1, 3], // 3
|
||||||
|
[1, 2], // 4
|
||||||
|
[0, 3, 1, 2], // 5 (saddle, two segments)
|
||||||
|
[0, 2], // 6
|
||||||
|
[2, 3], // 7
|
||||||
|
[2, 3], // 8
|
||||||
|
[0, 2], // 9
|
||||||
|
[0, 1, 2, 3], // 10 (saddle, two segments)
|
||||||
|
[1, 2], // 11
|
||||||
|
[1, 3], // 12
|
||||||
|
[0, 1], // 13
|
||||||
|
[0, 3], // 14
|
||||||
|
null, // 15
|
||||||
|
];
|
||||||
|
|
||||||
|
function edgePoint(edge, x, y, cell, v0, v1, v2, v3, threshold) {
|
||||||
|
var t;
|
||||||
|
switch (edge) {
|
||||||
|
case 0: // top: v0 -> v1
|
||||||
|
t = (threshold - v0) / (v1 - v0 || 1e-6);
|
||||||
|
return [x + cell * t, y];
|
||||||
|
case 1: // right: v1 -> v2
|
||||||
|
t = (threshold - v1) / (v2 - v1 || 1e-6);
|
||||||
|
return [x + cell, y + cell * t];
|
||||||
|
case 2: // bottom: v3 -> v2
|
||||||
|
t = (threshold - v3) / (v2 - v3 || 1e-6);
|
||||||
|
return [x + cell * t, y + cell];
|
||||||
|
case 3: // left: v0 -> v3
|
||||||
|
t = (threshold - v0) / (v3 - v0 || 1e-6);
|
||||||
|
return [x, y + cell * t];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup() {
|
// ---------------------------------------------------------------
|
||||||
frameRate(15);
|
// Layout state
|
||||||
const pixels = screenHeight * screenWidth;
|
// ---------------------------------------------------------------
|
||||||
const canvas = createCanvas(screenWidth, screenHeight);
|
var dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
|
||||||
canvas.parent('bg');
|
var width = 0;
|
||||||
for (let i = 0; i < pixels * density; i++) {
|
var height = 0;
|
||||||
balls.push(new Ball(
|
var cellSize = 28;
|
||||||
random(screenWidth),
|
var cols = 0;
|
||||||
random(screenHeight),
|
var rows = 0;
|
||||||
random(6) + 3,
|
var grid = null; // Float32Array of (cols+1)*(rows+1)
|
||||||
Math.exp(random(4) + 3) / 1000 + 1,
|
|
||||||
random(360)
|
var LEVELS = [-0.5, -0.333, -0.167, 0, 0.167, 0.333, 0.5];
|
||||||
));
|
var LOW_COLOR = [233, 230, 223]; // --ink, lowest elevation band
|
||||||
}
|
// A punchier red than --accent-bright (217,100,92) -- the .site overlay's
|
||||||
stroke(255);
|
// ~90% dilution desaturates whatever reaches it, so the top of the ramp
|
||||||
|
// needs to start more saturated than it should ever look at full opacity.
|
||||||
|
var HIGH_COLOR = [235, 70, 55];
|
||||||
|
|
||||||
|
// Two independent master levers: turn either down to quiet that part of
|
||||||
|
// the effect without touching the per-level tuning below. LINE_INTENSITY
|
||||||
|
// scales the contour strokes; FILL_INTENSITY scales the hypsometric wash
|
||||||
|
// between them (the fill covers far more area, so it wants a much lower
|
||||||
|
// resting value or the whole page tints).
|
||||||
|
var LINE_INTENSITY = 1;
|
||||||
|
var FILL_INTENSITY = 0.35;
|
||||||
|
// Fill is parked off for now (color/palette still being worked out) --
|
||||||
|
// the lines are the finished part. Flip this back on to resume tuning
|
||||||
|
// the fill without re-deriving any of the code below.
|
||||||
|
var FILL_ENABLED = false;
|
||||||
|
|
||||||
|
// Shared ink -> accent ramp, used by both the contour lines and the fill
|
||||||
|
// bands so they read as one coherent palette. `rank` is the band's
|
||||||
|
// position (0 = lowest), `count` the total number of bands on that scale.
|
||||||
|
// sqrt-biases toward color early, since a flat ramp only ever colors the
|
||||||
|
// single highest band.
|
||||||
|
function levelColor(rank, count) {
|
||||||
|
var t = Math.sqrt(rank / (count - 1));
|
||||||
|
return [
|
||||||
|
Math.round(LOW_COLOR[0] + (HIGH_COLOR[0] - LOW_COLOR[0]) * t),
|
||||||
|
Math.round(LOW_COLOR[1] + (HIGH_COLOR[1] - LOW_COLOR[1]) * t),
|
||||||
|
Math.round(LOW_COLOR[2] + (HIGH_COLOR[2] - LOW_COLOR[2]) * t),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function windowResized() {
|
// How many of LEVELS a value clears -- 0 (below every threshold) through
|
||||||
screenWidth = window.innerWidth + 10;
|
// LEVELS.length (above all of them). LEVELS is sorted ascending.
|
||||||
screenHeight = window.innerHeight + 10;
|
function bandIndex(v) {
|
||||||
resizeCanvas(screenWidth, screenHeight);
|
var idx = 0;
|
||||||
|
for (var i = 0; i < LEVELS.length; i++) {
|
||||||
|
if (v >= LEVELS[i]) idx = i + 1;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
var FILL_BANDS = LEVELS.length + 1;
|
||||||
background(24);
|
var bandColors = null; // precomputed once, reused every frame
|
||||||
|
function buildBandColors() {
|
||||||
|
bandColors = [];
|
||||||
|
for (var i = 0; i < FILL_BANDS; i++) bandColors.push(levelColor(i, FILL_BANDS));
|
||||||
|
}
|
||||||
|
buildBandColors();
|
||||||
|
|
||||||
for (let i = 0; i < balls.length; i++) {
|
// Samples per grid cell edge for the fill raster. Higher tracks the
|
||||||
balls[i].update();
|
// contour lines more precisely (at some per-frame cost); 4 keeps the
|
||||||
|
// boundary error under a few px, invisible once bilinear-upscaled.
|
||||||
|
var FILL_SUBDIV = 3;
|
||||||
|
var fillCanvas = document.createElement("canvas");
|
||||||
|
var fillCtx = fillCanvas.getContext("2d");
|
||||||
|
var fillImage = null;
|
||||||
|
var fillCols = 0;
|
||||||
|
var fillRows = 0;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
width = window.innerWidth;
|
||||||
|
height = window.innerHeight;
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
canvas.style.width = width + "px";
|
||||||
|
canvas.style.height = height + "px";
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
// Denser than the original line-only version needed: chaining +
|
||||||
|
// quadratic smoothing can only curve as finely as the underlying
|
||||||
|
// vertices allow, and at ~22px spacing sharp field bends still showed
|
||||||
|
// as visible facets (the actual cause of the "depth"/faceted look --
|
||||||
|
// confirmed by eye, not just measured).
|
||||||
|
var targetCols = 130;
|
||||||
|
cellSize = Math.max(11, width / targetCols);
|
||||||
|
cols = Math.ceil(width / cellSize) + 1;
|
||||||
|
rows = Math.ceil(height / cellSize) + 1;
|
||||||
|
grid = new Float32Array((cols + 1) * (rows + 1));
|
||||||
|
|
||||||
|
fillCols = cols * FILL_SUBDIV;
|
||||||
|
fillRows = rows * FILL_SUBDIV;
|
||||||
|
fillCanvas.width = fillCols;
|
||||||
|
fillCanvas.height = fillRows;
|
||||||
|
fillImage = fillCtx.createImageData(fillCols, fillRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw connection lines with additive blending so overlaps brighten
|
function sampleGrid(driftX, driftY, warpX, warpY) {
|
||||||
blendMode(ADD);
|
var idx = 0;
|
||||||
strokeWeight(2);
|
for (var ry = 0; ry <= rows; ry++) {
|
||||||
|
var y = ry * cellSize;
|
||||||
for (let i = 0; i < balls.length - 1; i++) {
|
for (var rx = 0; rx <= cols; rx++) {
|
||||||
const a = balls[i];
|
var x = rx * cellSize;
|
||||||
for (let j = i + 1; j < balls.length; j++) {
|
grid[idx++] = fieldValue(x, y, driftX, driftY, warpX, warpY);
|
||||||
const b = balls[j];
|
|
||||||
const dx = b.x - a.x;
|
|
||||||
const dy = b.y - a.y;
|
|
||||||
const distSquared = dx * dx + dy * dy;
|
|
||||||
|
|
||||||
if (distSquared < MAX_DIST_SQUARED) {
|
|
||||||
const distance = Math.sqrt(distSquared);
|
|
||||||
if (distance < 75) {
|
|
||||||
stroke(255, 85);
|
|
||||||
} else {
|
|
||||||
const chance = 0.3 ** (((random(0.2) + 0.8) * distance) / MAX_DIST);
|
|
||||||
stroke(255, chance < 0.5 ? 40 : 75);
|
|
||||||
}
|
|
||||||
line(a.x, a.y, b.x, b.y);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blendMode(BLEND);
|
// Hypsometric wash: for each fill-raster sample, bilinearly interpolate
|
||||||
|
// the RAW field value from its cell's four corners -- the same linear
|
||||||
|
// interpolation marching squares uses internally to place a line -- then
|
||||||
|
// band/color that interpolated value. Coloring first and blending colors
|
||||||
|
// second (as an earlier version did) is a different operation and drifts
|
||||||
|
// from the true boundary wherever a cell spans more than one threshold;
|
||||||
|
// interpolating the value first keeps fill and line mathematically tied
|
||||||
|
// to the same crossing.
|
||||||
|
function drawFill() {
|
||||||
|
var stride = cols + 1;
|
||||||
|
var pixels = fillImage.data;
|
||||||
|
var p = 0;
|
||||||
|
for (var ry = 0; ry < fillRows; ry++) {
|
||||||
|
var cy = Math.min(rows - 1, (ry / FILL_SUBDIV) | 0);
|
||||||
|
var fy = (ry - cy * FILL_SUBDIV) / FILL_SUBDIV;
|
||||||
|
var rowOff = cy * stride;
|
||||||
|
var rowOffNext = rowOff + stride;
|
||||||
|
for (var rx = 0; rx < fillCols; rx++) {
|
||||||
|
var cx = Math.min(cols - 1, (rx / FILL_SUBDIV) | 0);
|
||||||
|
var fx = (rx - cx * FILL_SUBDIV) / FILL_SUBDIV;
|
||||||
|
|
||||||
|
var v0 = grid[rowOff + cx];
|
||||||
|
var v1 = grid[rowOff + cx + 1];
|
||||||
|
var v2 = grid[rowOffNext + cx + 1];
|
||||||
|
var v3 = grid[rowOffNext + cx];
|
||||||
|
|
||||||
|
var top = v0 + (v1 - v0) * fx;
|
||||||
|
var bottom = v3 + (v2 - v3) * fx;
|
||||||
|
var value = top + (bottom - top) * fy;
|
||||||
|
|
||||||
|
var c = bandColors[bandIndex(value)];
|
||||||
|
pixels[p++] = c[0];
|
||||||
|
pixels[p++] = c[1];
|
||||||
|
pixels[p++] = c[2];
|
||||||
|
pixels[p++] = 255;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
fillCtx.putImageData(fillImage, 0, 0);
|
||||||
|
|
||||||
|
var alpha = Math.max(0, Math.min(1, FILL_INTENSITY));
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.drawImage(fillCanvas, 0, 0, fillCols, fillRows, 0, 0, width, height);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marching squares gives independent 2-point segments per cell, with no
|
||||||
|
// record of which segments abut. Stroking them as-is (one moveTo/lineTo
|
||||||
|
// subpath per segment) means lineJoin never gets a chance to apply, so
|
||||||
|
// every cell boundary shows as a hard facet. Chain segments that share
|
||||||
|
// an endpoint into continuous polylines/loops first, then stroke each
|
||||||
|
// chain as a quadratic-smoothed curve through its points -- an actually
|
||||||
|
// curved line instead of a connect-the-dots polygon.
|
||||||
|
var CHAIN_EPS = 0.02; // px; two crossings on the same shared edge should
|
||||||
|
// land on (near-)identical floats, see note below
|
||||||
|
function pointKey(p) {
|
||||||
|
return Math.round(p[0] / CHAIN_EPS) + "_" + Math.round(p[1] / CHAIN_EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSegments(threshold) {
|
||||||
|
var stride = cols + 1;
|
||||||
|
var segments = [];
|
||||||
|
for (var cy = 0; cy < rows; cy++) {
|
||||||
|
var rowOff = cy * stride;
|
||||||
|
var rowOffNext = rowOff + stride;
|
||||||
|
var y = cy * cellSize;
|
||||||
|
for (var cx = 0; cx < cols; cx++) {
|
||||||
|
var x = cx * cellSize;
|
||||||
|
var v0 = grid[rowOff + cx];
|
||||||
|
var v1 = grid[rowOff + cx + 1];
|
||||||
|
var v2 = grid[rowOffNext + cx + 1];
|
||||||
|
var v3 = grid[rowOffNext + cx];
|
||||||
|
|
||||||
|
var caseIndex =
|
||||||
|
(v0 >= threshold ? 1 : 0) |
|
||||||
|
(v1 >= threshold ? 2 : 0) |
|
||||||
|
(v2 >= threshold ? 4 : 0) |
|
||||||
|
(v3 >= threshold ? 8 : 0);
|
||||||
|
|
||||||
|
var edges = CASE_EDGES[caseIndex];
|
||||||
|
if (!edges) continue;
|
||||||
|
|
||||||
|
for (var s = 0; s < edges.length; s += 2) {
|
||||||
|
var p0 = edgePoint(edges[s], x, y, cellSize, v0, v1, v2, v3, threshold);
|
||||||
|
var p1 = edgePoint(edges[s + 1], x, y, cellSize, v0, v1, v2, v3, threshold);
|
||||||
|
segments.push([p0, p1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two adjacent cells that share a grid edge compute that edge's crossing
|
||||||
|
// point from the same two corner values via the same formula (verified:
|
||||||
|
// cell (cx,cy)'s right edge and cell (cx+1,cy)'s left edge reduce to an
|
||||||
|
// identical t), so their coordinates match to float precision -- rounding
|
||||||
|
// to a shared key reliably links them into one path.
|
||||||
|
function strokeChains(segments) {
|
||||||
|
var edgesForKey = {};
|
||||||
|
var pointsByKey = {};
|
||||||
|
|
||||||
|
function addPoint(p) {
|
||||||
|
var k = pointKey(p);
|
||||||
|
if (!pointsByKey[k]) pointsByKey[k] = p;
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < segments.length; i++) {
|
||||||
|
var ka = addPoint(segments[i][0]);
|
||||||
|
var kb = addPoint(segments[i][1]);
|
||||||
|
(edgesForKey[ka] = edgesForKey[ka] || []).push(kb);
|
||||||
|
(edgesForKey[kb] = edgesForKey[kb] || []).push(ka);
|
||||||
|
}
|
||||||
|
|
||||||
|
var visited = {};
|
||||||
|
function edgeId(k1, k2) {
|
||||||
|
return k1 < k2 ? k1 + "|" + k2 : k2 + "|" + k1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(startKey) {
|
||||||
|
var chain = [pointsByKey[startKey]];
|
||||||
|
var currentKey = startKey;
|
||||||
|
while (true) {
|
||||||
|
var neighbors = edgesForKey[currentKey] || [];
|
||||||
|
var nextKey = null;
|
||||||
|
for (var ni = 0; ni < neighbors.length; ni++) {
|
||||||
|
var eid = edgeId(currentKey, neighbors[ni]);
|
||||||
|
if (!visited[eid]) {
|
||||||
|
nextKey = neighbors[ni];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nextKey === null) break;
|
||||||
|
visited[edgeId(currentKey, nextKey)] = true;
|
||||||
|
chain.push(pointsByKey[nextKey]);
|
||||||
|
currentKey = nextKey;
|
||||||
|
if (currentKey === startKey) break; // closed loop
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
var k;
|
||||||
|
// Open chains first: any point with exactly one connection is an end.
|
||||||
|
for (k in edgesForKey) {
|
||||||
|
if (edgesForKey[k].length === 1) strokeChain(walk(k));
|
||||||
|
}
|
||||||
|
// Whatever's left over is closed loops with no natural start point.
|
||||||
|
for (k in edgesForKey) {
|
||||||
|
var neighbors = edgesForKey[k];
|
||||||
|
for (var ni = 0; ni < neighbors.length; ni++) {
|
||||||
|
if (!visited[edgeId(k, neighbors[ni])]) strokeChain(walk(k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quadratic-smoothed polyline: curve through the midpoint of each
|
||||||
|
// consecutive pair, using the shared point as control -- the standard
|
||||||
|
// cheap trick for turning a connect-the-dots path into a soft curve
|
||||||
|
// without full spline math.
|
||||||
|
// Catmull-Rom, not midpoint-quadratic: the earlier version curved *toward*
|
||||||
|
// each crossing point without ever reaching it (except chain endpoints),
|
||||||
|
// which is exactly why the line drifted from the fill after smoothing --
|
||||||
|
// the fill still bands on the true, unsmoothed crossing positions. A
|
||||||
|
// Catmull-Rom segment passes through every real point exactly and only
|
||||||
|
// uses neighbors to shape the tangent between them, so line and fill stay
|
||||||
|
// tied to the same positions with no possible corner-cutting drift.
|
||||||
|
function strokeChain(points) {
|
||||||
|
var n = points.length;
|
||||||
|
if (n < 2) return;
|
||||||
|
ctx.moveTo(points[0][0], points[0][1]);
|
||||||
|
if (n === 2) {
|
||||||
|
ctx.lineTo(points[1][0], points[1][1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < n - 1; i++) {
|
||||||
|
var p0 = points[i - 1] || points[i];
|
||||||
|
var p1 = points[i];
|
||||||
|
var p2 = points[i + 1];
|
||||||
|
var p3 = points[i + 2] || p2;
|
||||||
|
var c1x = p1[0] + (p2[0] - p0[0]) / 6;
|
||||||
|
var c1y = p1[1] + (p2[1] - p0[1]) / 6;
|
||||||
|
var c2x = p2[0] - (p3[0] - p1[0]) / 6;
|
||||||
|
var c2y = p2[1] - (p3[1] - p1[1]) / 6;
|
||||||
|
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2[0], p2[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawContours() {
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
|
||||||
|
for (var li = 0; li < LEVELS.length; li++) {
|
||||||
|
var threshold = LEVELS[li];
|
||||||
|
var isZero = threshold === 0;
|
||||||
|
// Rank-based, not value-based: the noise field's realized range
|
||||||
|
// rarely spans the full [-0.5, 0.5] of LEVELS (it's a weighted sum
|
||||||
|
// of two octaves, which clusters near the middle), so mapping color
|
||||||
|
// to the raw threshold left the reddest bands almost never drawn.
|
||||||
|
// Index position guarantees the full ink -> accent gradient shows
|
||||||
|
// up across whatever levels actually render. Same ramp as the fill.
|
||||||
|
var c = levelColor(li, LEVELS.length);
|
||||||
|
// Flat alpha across all non-zero bands: a depth-based falloff would
|
||||||
|
// dim the outer (most colorful) bands the most, directly undoing
|
||||||
|
// the color ramp. Color carries the elevation cue here, not brightness.
|
||||||
|
var alpha = (isZero ? 0.85 : 0.68) * LINE_INTENSITY;
|
||||||
|
ctx.strokeStyle = "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + alpha + ")";
|
||||||
|
// the zero level stays a hair bolder, like a coastline on a real
|
||||||
|
// topo map -- a reference line, not just another band.
|
||||||
|
ctx.lineWidth = isZero ? 1.8 : 1.4;
|
||||||
|
|
||||||
|
strokeChains(collectSegments(threshold));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(driftX, driftY, warpX, warpY) {
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
sampleGrid(driftX, driftY, warpX, warpY);
|
||||||
|
if (FILL_ENABLED) drawFill();
|
||||||
|
drawContours();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Animation: slow drift, throttled to a modest frame rate since the
|
||||||
|
// motion itself unfolds over tens of seconds -- no need to sample it
|
||||||
|
// faster than that.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
var DRIFT_VX = 3.2; // px/sec
|
||||||
|
var DRIFT_VY = -2.1; // px/sec
|
||||||
|
var startTime = null;
|
||||||
|
var lastFrameTime = 0;
|
||||||
|
var FRAME_INTERVAL = 1000 / 15; // ~15fps grid refresh, plenty for slow drift
|
||||||
|
var rafId = null;
|
||||||
|
|
||||||
|
function frame(now) {
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
if (now - lastFrameTime < FRAME_INTERVAL) return;
|
||||||
|
lastFrameTime = now;
|
||||||
|
if (startTime === null) startTime = now;
|
||||||
|
var t = (now - startTime) / 1000;
|
||||||
|
|
||||||
|
var driftX = t * DRIFT_VX;
|
||||||
|
var driftY = t * DRIFT_VY;
|
||||||
|
var warpX = Math.sin(t * 0.013) * 60;
|
||||||
|
var warpY = Math.cos(t * 0.009) * 60;
|
||||||
|
render(driftX, driftY, warpX, warpY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
resize();
|
||||||
|
if (reduceMotion) {
|
||||||
|
render(0, 0, 0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
var resizeTimer = null;
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
if (resizeTimer) clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(function () {
|
||||||
|
resize();
|
||||||
|
if (reduceMotion) render(0, 0, 0, 0);
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", function () {
|
||||||
|
if (reduceMotion) return;
|
||||||
|
if (document.hidden) {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
} else if (rafId === null) {
|
||||||
|
lastFrameTime = 0;
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
start();
|
||||||
|
})();
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ function markCurrentPage(location) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showNavNotice(message) {
|
||||||
|
const notice = document.getElementById('nav-notice');
|
||||||
|
if (!notice) return;
|
||||||
|
notice.textContent = message;
|
||||||
|
notice.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideNavNotice() {
|
||||||
|
const notice = document.getElementById('nav-notice');
|
||||||
|
if (notice) notice.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function goto(location, { push = true, hash = "" } = {}) {
|
async function goto(location, { push = true, hash = "" } = {}) {
|
||||||
const loadingBar = document.getElementById('loading-bar');
|
const loadingBar = document.getElementById('loading-bar');
|
||||||
|
|
||||||
@@ -47,6 +59,7 @@ async function goto(location, { push = true, hash = "" } = {}) {
|
|||||||
const [metadata, content] = await response.json();
|
const [metadata, content] = await response.json();
|
||||||
|
|
||||||
document.dispatchEvent(new Event('beforenavigate'));
|
document.dispatchEvent(new Event('beforenavigate'));
|
||||||
|
hideNavNotice();
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
root.innerHTML = content;
|
root.innerHTML = content;
|
||||||
@@ -79,6 +92,7 @@ async function goto(location, { push = true, hash = "" } = {}) {
|
|||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Navigation failed:", err);
|
console.error("Navigation failed:", err);
|
||||||
|
showNavNotice("Couldn't load that page. Check your connection and try again.");
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(loadingTimeout);
|
clearTimeout(loadingTimeout);
|
||||||
if (loadingBar && loadingBar.classList.contains('active')) {
|
if (loadingBar && loadingBar.classList.contains('active')) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ async function fetchStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateStatusDisplay(data) {
|
function updateStatusDisplay(data) {
|
||||||
|
showStaleNotice(!!data.stale);
|
||||||
|
|
||||||
if (data.last_check) {
|
if (data.last_check) {
|
||||||
const lastCheck = new Date(data.last_check);
|
const lastCheck = new Date(data.last_check);
|
||||||
const lastUpdateEl = document.getElementById('lastUpdate');
|
const lastUpdateEl = document.getElementById('lastUpdate');
|
||||||
@@ -197,6 +199,28 @@ function showError(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persistent (not auto-dismissed) notice that the last successful check is
|
||||||
|
// older than expected -- the monitor may have hit a snag, so don't let the
|
||||||
|
// page keep implying the numbers below are fresh.
|
||||||
|
function showStaleNotice(isStale) {
|
||||||
|
const container = document.querySelector('.page');
|
||||||
|
if (!container) return;
|
||||||
|
let noticeEl = document.getElementById('staleNotice');
|
||||||
|
|
||||||
|
if (!isStale) {
|
||||||
|
if (noticeEl) noticeEl.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!noticeEl) {
|
||||||
|
noticeEl = document.createElement('div');
|
||||||
|
noticeEl.id = 'staleNotice';
|
||||||
|
noticeEl.className = 'status-notice';
|
||||||
|
noticeEl.textContent = 'Data below may be out of date -- the last successful check was longer ago than expected.';
|
||||||
|
container.insertBefore(noticeEl, container.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshStatus() {
|
function refreshStatus() {
|
||||||
const refreshBtn = document.getElementById('refreshBtn');
|
const refreshBtn = document.getElementById('refreshBtn');
|
||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
|
|||||||
@@ -80,12 +80,12 @@
|
|||||||
"No, They Can't": {
|
"No, They Can't": {
|
||||||
"filename": "no-they-cant.jpeg",
|
"filename": "no-they-cant.jpeg",
|
||||||
"link": "https://www.goodreads.com/book/show/13260131-no-they-can-t",
|
"link": "https://www.goodreads.com/book/show/13260131-no-they-can-t",
|
||||||
"review": "I much preferred Give Me A Break (which I read first). Sossel's writing style in this book is less developed - it feels aimless and with an intent to lecture."
|
"review": "I much preferred Give Me A Break (which I read first). Stossel's writing style in this book is less developed - it feels aimless and with an intent to lecture."
|
||||||
},
|
},
|
||||||
"Give Me a Break": {
|
"Give Me a Break": {
|
||||||
"filename": "giveMeABreak.jpeg",
|
"filename": "giveMeABreak.jpeg",
|
||||||
"link": "https://www.amazon.com/Give-Me-Break-Exposed-Hucksters-ebook/dp/B000FC2NF8/",
|
"link": "https://www.amazon.com/Give-Me-Break-Exposed-Hucksters-ebook/dp/B000FC2NF8/",
|
||||||
"review": "I expected a boring autobiography-type book, but instead is a glimpse inside Stossel's work that transformed itself as it transformed his view. Was very happy to see a figure of similar personal ideology. Probably made it a little too easy to swallow that pill."
|
"review": "I expected a boring autobiography-type book, but instead is a glimpse inside Stossel's work that transformed itself as it transformed his view."
|
||||||
},
|
},
|
||||||
"Reign of Terror": {
|
"Reign of Terror": {
|
||||||
"filename": "reignofterror.jpg",
|
"filename": "reignofterror.jpg",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"The Scout Mindset": {
|
"The Scout Mindset": {
|
||||||
"filename": "scoutMindset.png",
|
"filename": "scoutMindset.png",
|
||||||
"link": "https://www.amazon.com/Scout-Mindset-People-Things-Clearly-ebook/dp/B07L2HQ26K/",
|
"link": "https://www.amazon.com/Scout-Mindset-People-Things-Clearly-ebook/dp/B07L2HQ26K/",
|
||||||
"review": "Felt like a list of things that I already do that I should be more mindful of. Maybe that's just me. There was some interesting mental probablism sprinkled in the first half but the second half did not have much new to say. Good but not eye-opening."
|
"review": "Felt like a list of things that I already do that I should be more mindful of. Maybe that's just me. There was some interesting mental probabilism sprinkled in the first half but the second half did not have much new to say. Good but not eye-opening."
|
||||||
},
|
},
|
||||||
"Verbal Judo": {
|
"Verbal Judo": {
|
||||||
"filename": "verbalJudo.png",
|
"filename": "verbalJudo.png",
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
"Where Good Ideas Come From": {
|
"Where Good Ideas Come From": {
|
||||||
"filename": "where-good-ideas-come-from.png",
|
"filename": "where-good-ideas-come-from.png",
|
||||||
"link": "https://www.goodreads.com/book/show/8034188-where-good-ideas-come-from",
|
"link": "https://www.goodreads.com/book/show/8034188-where-good-ideas-come-from",
|
||||||
"review": "I got this book at a recycling center. I didn't want to read it or like it. Unfortnuately, it's pretty good. 200 pages of considerate review of how innovation comes to be + suggestions to expand the utility of your ideas (I've adopted several!)"
|
"review": "I got this book at a recycling center. I didn't want to read it or like it. Unfortunately, it's pretty good. 200 pages of considerate review of how innovation comes to be + suggestions to expand the utility of your ideas (I've adopted several!)"
|
||||||
},
|
},
|
||||||
"12 Rules for Life": {
|
"12 Rules for Life": {
|
||||||
"filename": "12RulesForLife.jpg",
|
"filename": "12RulesForLife.jpg",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"links": [
|
"links": [
|
||||||
[
|
[
|
||||||
"globe",
|
"globe",
|
||||||
"http://files.asimonson.com/u/AIcodeSmells.pdf",
|
"https://files.asimonson.com/u/AIcodeSmells.pdf",
|
||||||
"Paper"
|
"Paper"
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"links": [
|
"links": [
|
||||||
[
|
[
|
||||||
"globe",
|
"globe",
|
||||||
"http://files.asimonson.com/u/blanketTrust.pdf",
|
"https://files.asimonson.com/u/blanketTrust.pdf",
|
||||||
"Paper"
|
"Paper"
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"Portfolio Website": {
|
"Portfolio Website": {
|
||||||
"status": "complete",
|
"status": "complete",
|
||||||
"classes": "programming",
|
"classes": "programming",
|
||||||
"content": "This website is my personal sandbox where I've integrated some of my data projects via docker cluster. It is self hosted and zero-trust secure while remaining dynamic and free of the tech debt that comes with pre-designed sites and excessive framework application. Yeah, I can do E2E.",
|
"content": "This website is my personal sandbox where I've integrated some of my data projects via docker cluster. It is self hosted and dynamic, generally free of the tech debt that comes with pre-designed sites and excessive framework application. Yeah, I can do E2E.",
|
||||||
"links": [
|
"links": [
|
||||||
["globe", "https://asimonson.com", "Homepage"],
|
["globe", "https://asimonson.com", "Homepage"],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
<svg width="350" height="165" viewBox="0 0 350 165" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
|
||||||
<title id="titleId"/>
|
|
||||||
<desc id="descId"/>
|
|
||||||
<style>
|
|
||||||
.header {
|
|
||||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
|
||||||
fill: #fabd2f;
|
|
||||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
@supports(-moz-appearance: auto) {
|
|
||||||
/* Selector detects Firefox */
|
|
||||||
.header { font-size: 15.5px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInAnimation {
|
|
||||||
from {
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
width: calc(100%-100px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes growWidthAnimation {
|
|
||||||
from {
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.lang-name {
|
|
||||||
font: 400 11px "Segoe UI", Ubuntu, Sans-Serif;
|
|
||||||
fill: #8ec07c;
|
|
||||||
}
|
|
||||||
.stagger {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
#rect-mask rect{
|
|
||||||
animation: slideInAnimation 1s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
.lang-progress{
|
|
||||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes scaleInAnimation {
|
|
||||||
from {
|
|
||||||
transform: translate(-5px, 5px) scale(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translate(-5px, 5px) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes fadeInAnimation {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="349" fill="#282828" stroke-opacity="1"/>
|
|
||||||
|
|
||||||
|
|
||||||
<g data-testid="card-title" transform="translate(25, 35)">
|
|
||||||
<g transform="translate(0, 0)">
|
|
||||||
<text x="0" y="0" class="header" data-testid="header">Most Used Languages</text>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
|
|
||||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
|
||||||
|
|
||||||
<svg data-testid="lang-items" x="25">
|
|
||||||
|
|
||||||
<mask id="rect-mask">
|
|
||||||
<rect x="0" y="0" width="300" height="8" fill="white" rx="5"/>
|
|
||||||
</mask>
|
|
||||||
|
|
||||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="0" y="0" width="128.34" height="8" fill="#f1e05a"/>
|
|
||||||
|
|
||||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="128.34" y="0" width="63.46" height="8" fill="#f34b7d"/>
|
|
||||||
|
|
||||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="191.8" y="0" width="49.7" height="8" fill="#3572A5"/>
|
|
||||||
|
|
||||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="241.5" y="0" width="33.69" height="8" fill="#e34c26"/>
|
|
||||||
|
|
||||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="275.19" y="0" width="24.81" height="8" fill="#563d7c"/>
|
|
||||||
|
|
||||||
|
|
||||||
<g transform="translate(0, 25)">
|
|
||||||
<g transform="translate(0, 0)"><g transform="translate(0, 0)">
|
|
||||||
<g class="stagger" style="animation-delay: 450ms">
|
|
||||||
<circle cx="5" cy="6" r="5" fill="#f1e05a"/>
|
|
||||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
|
||||||
JavaScript 42.78%
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</g><g transform="translate(0, 25)">
|
|
||||||
<g class="stagger" style="animation-delay: 600ms">
|
|
||||||
<circle cx="5" cy="6" r="5" fill="#f34b7d"/>
|
|
||||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
|
||||||
C++ 21.15%
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</g><g transform="translate(0, 50)">
|
|
||||||
<g class="stagger" style="animation-delay: 750ms">
|
|
||||||
<circle cx="5" cy="6" r="5" fill="#3572A5"/>
|
|
||||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
|
||||||
Python 16.57%
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</g></g><g transform="translate(150, 0)"><g transform="translate(0, 0)">
|
|
||||||
<g class="stagger" style="animation-delay: 450ms">
|
|
||||||
<circle cx="5" cy="6" r="5" fill="#e34c26"/>
|
|
||||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
|
||||||
HTML 11.23%
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</g><g transform="translate(0, 25)">
|
|
||||||
<g class="stagger" style="animation-delay: 600ms">
|
|
||||||
<circle cx="5" cy="6" r="5" fill="#563d7c"/>
|
|
||||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
|
||||||
CSS 8.27%
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</g></g>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,11 +1,27 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://asimonson.com</loc>
|
<loc>https://asimonson.com/</loc>
|
||||||
|
<lastmod>2026-09-22</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://asimonson.com/about</loc>
|
||||||
|
<lastmod>2026-09-22</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
<loc>https://asimonson.com/projects</loc>
|
<loc>https://asimonson.com/projects</loc>
|
||||||
<loc>https://asimonson.com/Resume</loc>
|
<lastmod>2026-09-22</lastmod>
|
||||||
<loc>https://asimonson.com/duck</loc>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://asimonson.com/books</loc>
|
||||||
|
<lastmod>2026-09-22</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
<loc>https://asimonson.com/status</loc>
|
<loc>https://asimonson.com/status</loc>
|
||||||
<lastmod>2026-02-12</lastmod>
|
<lastmod>2026-09-22</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://asimonson.com/resume</loc>
|
||||||
|
<lastmod>2026-09-22</lastmod>
|
||||||
</url>
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
<h1 class="display">About</h1>
|
<h1 class="display">About</h1>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
I'm Andrew Simonson, a data scientist at Ecolab and a graduate data
|
I'm Andrew Simonson, a data scientist at Ecolab and a graduate data
|
||||||
science student at Rochester Institute of Technology, where I recently
|
science student at Rochester Institute of Technology, pursuing an M.S.
|
||||||
finished a B.S. in Computer Science with a minor in international
|
in Data Science after finishing a B.S. in Computer Science with a
|
||||||
relations and a focus on probability theory.
|
minor in international relations and a focus on probability theory.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<div class="cert-group">
|
<div class="cert-group">
|
||||||
<h3>Data Science MicroMasters</h3>
|
<h3>Data Science MicroMasters</h3>
|
||||||
<p class="cert-provider">UC San Diego on edX ·
|
<p class="cert-provider">UC San Diego on edX ·
|
||||||
<a href="http://credentials.edx.org/credentials/4b7e78dca8154c0d88ca9abc5aedb4ac" rel="noopener noreferrer">program certificate</a>
|
<a href="https://credentials.edx.org/credentials/4b7e78dca8154c0d88ca9abc5aedb4ac" rel="noopener noreferrer">program certificate</a>
|
||||||
</p>
|
</p>
|
||||||
<ul class="cert-list">
|
<ul class="cert-list">
|
||||||
<li><a href="https://courses.edx.org/certificates/b6deccc56e5344ae84cb55f9ad81fd79" rel="noopener noreferrer">Python for Data Science</a> <span class="cert-code">DSE200x</span></li>
|
<li><a href="https://courses.edx.org/certificates/b6deccc56e5344ae84cb55f9ad81fd79" rel="noopener noreferrer">Python for Data Science</a> <span class="cert-code">DSE200x</span></li>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
<!-- Resource hints for performance -->
|
<!-- Resource hints for performance -->
|
||||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
|
|
||||||
<meta name="description" content="{{ var['description'] }}" />
|
<meta name="description" content="{{ var['description'] }}" />
|
||||||
<meta property="og:title" content="Andrew Simonson" />
|
<meta property="og:title" content="Andrew Simonson" />
|
||||||
<meta property="og:description" content="{{ var['description'] }}" />
|
<meta property="og:description" content="{{ var['description'] }}" />
|
||||||
@@ -76,13 +75,13 @@
|
|||||||
<link rel="canonical" href="{{ request.url_root | trim('/') }}{{ var['canonical'] }}" />
|
<link rel="canonical" href="{{ request.url_root | trim('/') }}{{ var['canonical'] }}" />
|
||||||
<script defer src="{{ url_for('static', filename='js/responsive.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/responsive.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/idler.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/idler.js') }}"></script>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/p5@1.4.1/lib/p5.min.js"></script>
|
|
||||||
<title>{{ var['title'] }}</title>
|
<title>{{ var['title'] }}</title>
|
||||||
</head>
|
</head>
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<body onpopstate="backButton()">
|
<body onpopstate="backButton()">
|
||||||
<a class="skip-link" href="#root">Skip to content</a>
|
<a class="skip-link" href="#root">Skip to content</a>
|
||||||
<div id="loading-bar" aria-hidden="true"></div>
|
<div id="loading-bar" aria-hidden="true"></div>
|
||||||
|
<div id="nav-notice" class="nav-notice" role="alert" hidden></div>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="bg" aria-hidden="true"></div>
|
<div id="bg" aria-hidden="true"></div>
|
||||||
<div class="site">
|
<div class="site">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<section class="section" aria-labelledby="now-heading">
|
<section class="section" aria-labelledby="now-heading">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2 id="now-heading">Now</h2>
|
<h2 id="now-heading">Now</h2>
|
||||||
<p class="section-note">September 2026</p>
|
<p class="section-note">Today</p>
|
||||||
</div>
|
</div>
|
||||||
<dl class="now-list">
|
<dl class="now-list">
|
||||||
<div class="now-item">
|
<div class="now-item">
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
<span>{{ f.summary or f.tagline }}</span>
|
<span>{{ f.summary or f.tagline }}</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section" aria-labelledby="site-heading">
|
<section class="section" aria-labelledby="site-heading">
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
<p>
|
<p>
|
||||||
I wasn't really expecting anyone to read this far.
|
I wasn't really expecting anyone to read this far.
|
||||||
If you're looking for shared resources you've taken a wrong turn.
|
If you're looking for shared resources you've taken a wrong turn.
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
From here, we'll rekindle the future.
|
From here, we'll rekindle the future.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/hotspots.css') }}" />
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
crossorigin=""
|
|
||||||
/>
|
|
||||||
<script
|
|
||||||
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
||||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
||||||
crossorigin=""
|
|
||||||
></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/lib/leaflet-providers.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/lib/CUSTOM.leaflet.curve.js') }}"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="map"></div>
|
|
||||||
<script src="{{ url_for('static', filename='js/hotspots.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{% extends "header.html" %}
|
|
||||||
{% block header %}{% endblock %}
|
|
||||||
{% block footer %}{% endblock %}
|
|
||||||
{% block content %}<iframe id="fullIframe" src="{{ url }}" title="HotspotsRIT"></iframe>{% endblock %}
|
|
||||||
Reference in New Issue
Block a user