Compare commits
11 Commits
7a585d79ed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dfa539783d | |||
| e76d7c499a | |||
| 062b76af09 | |||
| bbbf9795bc | |||
| 8353ecc38c | |||
| d73ed21719 | |||
| c6fa2c41a7 | |||
| 8432cc0bf6 | |||
| 21ebd13a07 | |||
| 78aa49c0f3 | |||
| 9fc5bfe9c7 |
@@ -1,7 +1,7 @@
|
||||
# Service Status Monitor
|
||||
|
||||
## 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
|
||||
|
||||
@@ -9,20 +9,22 @@ Server-side monitoring system that checks the availability of asimonson.com serv
|
||||
|
||||
#### 1. `monitor.py` - Service Monitoring Module
|
||||
- **Purpose**: Performs automated health checks on all services
|
||||
- **Check Interval**: Every 2 hours (7200 seconds)
|
||||
- **Check Interval**: Every 60 seconds
|
||||
- **Services Monitored**:
|
||||
- asimonson.com
|
||||
- files.asimonson.com
|
||||
- git.asimonson.com
|
||||
- pass.asimonson.com
|
||||
- ssh.asimonson.com
|
||||
|
||||
**Features**:
|
||||
- Tracks response times and HTTP status codes
|
||||
- Calculates uptime percentages for multiple time periods (24h, 7d, 30d, all-time)
|
||||
- Persists data to PostgreSQL (`service_checks` table) via `DATABASE_URL` env var
|
||||
- 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
|
||||
- **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
|
||||
- Shows total number of checks performed
|
||||
- Manual refresh button
|
||||
- Auto-refreshes every 5 minutes
|
||||
- Auto-refreshes every 60 seconds
|
||||
|
||||
#### 2. `static/js/status.js` - Frontend Logic
|
||||
- Fetches status data from `/api/status` API
|
||||
- Updates UI with service status and uptime
|
||||
- Handles error states gracefully
|
||||
- Auto-refresh every 5 minutes
|
||||
- Shows a dismissible-on-refresh notice banner when the response is a fetch
|
||||
error, and a persistent notice when the backend flags the data as `stale`
|
||||
- Auto-refresh every 60 seconds
|
||||
|
||||
#### 3. `static/css/App.css` - Styling
|
||||
- Color-coded status indicators:
|
||||
@@ -99,7 +102,7 @@ cd src
|
||||
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
|
||||
Navigate to: `https://asimonson.com/status`
|
||||
@@ -115,7 +118,7 @@ To modify monitoring behavior, edit `src/monitor.py`:
|
||||
|
||||
```python
|
||||
# Change check interval (in seconds)
|
||||
CHECK_INTERVAL = 7200 # 2 hours
|
||||
CHECK_INTERVAL = 60 # 1 minute
|
||||
|
||||
# Modify service list
|
||||
SERVICES = [
|
||||
@@ -133,6 +136,6 @@ SERVICES = [
|
||||
|
||||
- First deployment will show limited uptime data until enough checks accumulate
|
||||
- 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
|
||||
- All checks performed server-side (no client-side CORS issues)
|
||||
|
||||
29
src/app.py
@@ -1,15 +1,21 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import flask
|
||||
from flask_minify import Minify
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
import werkzeug.exceptions as HTTPerror
|
||||
|
||||
import config # noqa: F401 — side-effect: loads dev env vars
|
||||
from monitor import monitor, SERVICES
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
# Trust the scheme/host Cloudflare sets, so redirects Werkzeug builds itself
|
||||
# (e.g. the trailing-slash redirect on /api/goto) use https instead of the
|
||||
# plaintext scheme it sees on its own socket.
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
|
||||
# ── Static file fingerprinting ────────────────────────────────────────
|
||||
|
||||
@@ -17,7 +23,7 @@ static_file_hashes = {}
|
||||
for dirpath, _, filenames in os.walk(app.static_folder):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
relative = os.path.relpath(filepath, app.static_folder)
|
||||
relative = os.path.relpath(filepath, app.static_folder).replace(os.sep, '/')
|
||||
with open(filepath, 'rb') as f:
|
||||
static_file_hashes[relative] = hashlib.md5(f.read()).hexdigest()[:8]
|
||||
|
||||
@@ -42,6 +48,9 @@ def add_headers(response):
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
|
||||
if flask.request.is_secure:
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000'
|
||||
|
||||
if flask.request.path.startswith('/static/'):
|
||||
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||
elif flask.request.path in ['/sitemap.xml', '/robots.txt']:
|
||||
@@ -55,22 +64,34 @@ def add_headers(response):
|
||||
# ── Load page data ────────────────────────────────────────────────────
|
||||
|
||||
def load_json(path):
|
||||
with open(path, "r") as f:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
projects = load_json("./static/json/projects.json")
|
||||
books = load_json("./static/json/books.json")
|
||||
skills = load_json("./static/json/skills.json")
|
||||
timeline = load_json("./static/json/timeline.json")
|
||||
pages = load_json("./static/json/pages.json")
|
||||
features = load_json("./static/json/features.json")
|
||||
|
||||
pages['projects']['skillList'] = skills
|
||||
pages['about']['skillList'] = skills
|
||||
pages['projects']['projects'] = projects
|
||||
pages['projects']['features'] = features
|
||||
pages['home']['features'] = features
|
||||
pages['home']['books'] = books
|
||||
pages['home']['projects'] = projects
|
||||
pages['books']['books'] = books
|
||||
pages['status']['services'] = SERVICES
|
||||
|
||||
for _name, _page in pages.items():
|
||||
_page['id'] = _name
|
||||
|
||||
|
||||
@app.template_filter('slug')
|
||||
def slug(value):
|
||||
value = re.sub(r'[^a-z0-9]+', '-', str(value).lower())
|
||||
return value.strip('-')
|
||||
|
||||
|
||||
# ── Error rendering ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@ SERVICES = [
|
||||
# {'id': 'EternalRelays', 'name': 'eternalrelays.com', 'url': 'https://eternalrelays.com', 'timeout': 10},
|
||||
{'id': 'files', 'name': 'files.asimonson.com', 'url': 'https://files.asimonson.com', 'timeout': 10},
|
||||
{'id': 'git', 'name': 'git.asimonson.com', 'url': 'https://git.asimonson.com', 'timeout': 10},
|
||||
{'id': 'cascadalyst', 'name': 'cascadalyst.com', 'url': 'https://cascadalyst.com', 'timeout': 10},
|
||||
# {'id': 'cascadalyst', 'name': 'cascadalyst.com', 'url': 'https://cascadalyst.com', 'timeout': 10},
|
||||
]
|
||||
|
||||
CHECK_INTERVAL = 60 # seconds between checks
|
||||
RETENTION_DAYS = 90 # how long to keep records
|
||||
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')
|
||||
|
||||
@@ -51,6 +52,26 @@ class ServiceMonitor:
|
||||
for svc in SERVICES
|
||||
}
|
||||
self._last_check = None
|
||||
# Cheap placeholder (no DB access) until the first check_all_services()
|
||||
# run replaces it with the real, uptime-backed summary.
|
||||
self._cached_summary = {
|
||||
'last_check': None,
|
||||
'next_check': None,
|
||||
'services': [
|
||||
{
|
||||
'id': service_id,
|
||||
'name': cached['name'],
|
||||
'url': cached['url'],
|
||||
'status': cached['status'],
|
||||
'response_time': cached['response_time'],
|
||||
'status_code': cached['status_code'],
|
||||
'last_online': cached['last_online'],
|
||||
'uptime': {'24h': None, '7d': None, '30d': None, 'all_time': None},
|
||||
'total_checks': 0,
|
||||
}
|
||||
for service_id, cached in self._current.items()
|
||||
],
|
||||
}
|
||||
self._ensure_schema()
|
||||
|
||||
# ── Database helpers ──────────────────────────────────────────
|
||||
@@ -206,6 +227,7 @@ class ServiceMonitor:
|
||||
if result['status'] == 'online':
|
||||
cached['last_online'] = result['timestamp']
|
||||
self._last_check = datetime.now().isoformat()
|
||||
self._cached_summary = self._build_summary_locked()
|
||||
|
||||
# ── Uptime calculations ───────────────────────────────────────
|
||||
|
||||
@@ -271,9 +293,13 @@ class ServiceMonitor:
|
||||
|
||||
# ── Status summary ────────────────────────────────────────────
|
||||
|
||||
def get_status_summary(self):
|
||||
"""Build a JSON-serializable status summary with uptime statistics."""
|
||||
with self.lock:
|
||||
def _build_summary_locked(self):
|
||||
"""Build a JSON-serializable status summary with uptime statistics.
|
||||
|
||||
Hits the database (multiple queries per service) -- only call this
|
||||
from within check_all_services() while self.lock is held, so it runs
|
||||
once per check cycle rather than once per /api/status request.
|
||||
"""
|
||||
summary = {
|
||||
'last_check': self._last_check,
|
||||
'next_check': None,
|
||||
@@ -304,6 +330,24 @@ class ServiceMonitor:
|
||||
|
||||
return summary
|
||||
|
||||
def get_status_summary(self):
|
||||
"""Return the cached status summary, refreshed once per check cycle
|
||||
(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:
|
||||
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 ───────────────────────────────────────────
|
||||
|
||||
def _purge_old_records(self):
|
||||
@@ -321,21 +365,37 @@ class ServiceMonitor:
|
||||
finally:
|
||||
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):
|
||||
"""Start the background daemon thread for periodic checks and cleanup."""
|
||||
def monitor_loop():
|
||||
self.check_all_services()
|
||||
self._purge_old_records()
|
||||
self._run_check_cycle()
|
||||
self._run_cleanup()
|
||||
|
||||
checks_since_cleanup = 0
|
||||
checks_per_cleanup = CLEANUP_INTERVAL // CHECK_INTERVAL
|
||||
|
||||
while True:
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
self.check_all_services()
|
||||
self._run_check_cycle()
|
||||
checks_since_cleanup += 1
|
||||
if checks_since_cleanup >= checks_per_cleanup:
|
||||
self._purge_old_records()
|
||||
self._run_cleanup()
|
||||
checks_since_cleanup = 0
|
||||
|
||||
thread = Thread(target=monitor_loop, daemon=True)
|
||||
|
||||
@@ -5,7 +5,6 @@ click==8.4.1
|
||||
Flask==3.1.3
|
||||
Flask-Minify==0.50
|
||||
gunicorn==26.0.0
|
||||
htmlminf==0.1.13
|
||||
idna==3.16
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
@@ -1,104 +1,521 @@
|
||||
const balls = [];
|
||||
const density = 0.00005;
|
||||
let screenWidth = window.innerWidth + 10;
|
||||
let screenHeight = window.innerHeight + 10;
|
||||
/**
|
||||
* Ambient background: "Contour Field"
|
||||
*
|
||||
* 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;
|
||||
const MAX_DIST_SQUARED = MAX_DIST * MAX_DIST;
|
||||
var container = document.getElementById("bg");
|
||||
if (!container) return;
|
||||
|
||||
class Ball {
|
||||
constructor(x, y, size, speed, angle) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.size = size;
|
||||
this.speed = speed;
|
||||
this.angle = angle;
|
||||
this.calcChange();
|
||||
var canvas = document.createElement("canvas");
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
container.appendChild(canvas);
|
||||
var ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
var reduceMotion =
|
||||
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() {
|
||||
const radians = (this.angle * Math.PI) / 180
|
||||
this.xSpeed = this.speed * Math.sin(radians);
|
||||
this.ySpeed = this.speed * Math.cos(radians);
|
||||
function fade(t) {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.x += this.xSpeed;
|
||||
this.y += this.ySpeed;
|
||||
if (this.x > screenWidth) {
|
||||
this.x -= screenWidth;
|
||||
} else if (this.x < 0) {
|
||||
this.x += screenWidth;
|
||||
}
|
||||
if (this.y > screenHeight) {
|
||||
this.y -= screenHeight;
|
||||
} else if (this.y < 0) {
|
||||
this.y += screenHeight;
|
||||
}
|
||||
this.draw();
|
||||
function noise2D(x, y) {
|
||||
var xi = Math.floor(x);
|
||||
var yi = Math.floor(y);
|
||||
var xf = x - xi;
|
||||
var yf = y - yi;
|
||||
var u = fade(xf);
|
||||
var v = fade(yf);
|
||||
var n00 = lattice(xi, yi);
|
||||
var n10 = lattice(xi + 1, yi);
|
||||
var n01 = lattice(xi, yi + 1);
|
||||
var n11 = lattice(xi + 1, yi + 1);
|
||||
var nx0 = n00 + (n10 - n00) * u;
|
||||
var nx1 = n01 + (n11 - n01) * u;
|
||||
return nx0 + (nx1 - nx0) * v;
|
||||
}
|
||||
|
||||
draw() {
|
||||
stroke(200, 100);
|
||||
strokeWeight(2);
|
||||
fill(0);
|
||||
ellipse(this.x, this.y, this.size, this.size);
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
frameRate(15);
|
||||
const pixels = screenHeight * screenWidth;
|
||||
createCanvas(screenWidth, screenHeight);
|
||||
for (let i = 0; i < pixels * density; i++) {
|
||||
balls.push(new Ball(
|
||||
random(screenWidth),
|
||||
random(screenHeight),
|
||||
random(6) + 3,
|
||||
Math.exp(random(4) + 3) / 1000 + 1,
|
||||
random(360)
|
||||
));
|
||||
}
|
||||
stroke(255);
|
||||
}
|
||||
|
||||
function windowResized() {
|
||||
screenWidth = window.innerWidth + 10;
|
||||
screenHeight = window.innerHeight + 10;
|
||||
resizeCanvas(screenWidth, screenHeight);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
background(24);
|
||||
|
||||
for (let i = 0; i < balls.length; i++) {
|
||||
balls[i].update();
|
||||
// Two-octave fractal sum, warped slightly so the field folds and
|
||||
// breathes rather than just sliding sideways.
|
||||
var FREQ_1 = 1 / 460; // px per full noise cycle, octave 1
|
||||
var FREQ_2 = FREQ_1 * 2.3; // octave 2
|
||||
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
|
||||
}
|
||||
|
||||
// Draw connection lines with additive blending so overlaps brighten
|
||||
blendMode(ADD);
|
||||
strokeWeight(2);
|
||||
// ---------------------------------------------------------------
|
||||
// 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
|
||||
];
|
||||
|
||||
for (let i = 0; i < balls.length - 1; i++) {
|
||||
const a = balls[i];
|
||||
for (let j = i + 1; j < balls.length; j++) {
|
||||
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);
|
||||
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];
|
||||
}
|
||||
line(a.x, a.y, b.x, b.y);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Layout state
|
||||
// ---------------------------------------------------------------
|
||||
var dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
|
||||
var width = 0;
|
||||
var height = 0;
|
||||
var cellSize = 28;
|
||||
var cols = 0;
|
||||
var rows = 0;
|
||||
var grid = null; // Float32Array of (cols+1)*(rows+1)
|
||||
|
||||
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
|
||||
// ~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),
|
||||
];
|
||||
}
|
||||
|
||||
// How many of LEVELS a value clears -- 0 (below every threshold) through
|
||||
// LEVELS.length (above all of them). LEVELS is sorted ascending.
|
||||
function bandIndex(v) {
|
||||
var idx = 0;
|
||||
for (var i = 0; i < LEVELS.length; i++) {
|
||||
if (v >= LEVELS[i]) idx = i + 1;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
var FILL_BANDS = LEVELS.length + 1;
|
||||
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();
|
||||
|
||||
// Samples per grid cell edge for the fill raster. Higher tracks the
|
||||
// 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);
|
||||
}
|
||||
|
||||
function sampleGrid(driftX, driftY, warpX, warpY) {
|
||||
var idx = 0;
|
||||
for (var ry = 0; ry <= rows; ry++) {
|
||||
var y = ry * cellSize;
|
||||
for (var rx = 0; rx <= cols; rx++) {
|
||||
var x = rx * cellSize;
|
||||
grid[idx++] = fieldValue(x, y, driftX, driftY, warpX, warpY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
function toggleMenu(collapse) {
|
||||
if (window.innerWidth < 1400) {
|
||||
const menu = document.querySelector(".navControl");
|
||||
const bar = document.querySelector(".header");
|
||||
const isCollapsed = !menu.style.maxHeight || menu.style.maxHeight === "0px";
|
||||
if (isCollapsed && !collapse) {
|
||||
menu.style.maxHeight = `${menu.scrollHeight + 10}px`;
|
||||
bar.style.borderBottomWidth = "0px";
|
||||
} else {
|
||||
menu.style.maxHeight = "0px";
|
||||
bar.style.borderBottomWidth = "3px";
|
||||
}
|
||||
}
|
||||
const header = document.getElementById("siteHeader");
|
||||
const btn = document.getElementById("menu");
|
||||
if (!header) return;
|
||||
const open = collapse ? false : !header.classList.contains("open");
|
||||
header.classList.toggle("open", open);
|
||||
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
|
||||
async function goto(location, { push = true } = {}) {
|
||||
function markCurrentPage(location) {
|
||||
document.querySelectorAll(".site-nav a[data-page]").forEach(function (link) {
|
||||
if (link.dataset.page === location) {
|
||||
link.setAttribute("aria-current", "page");
|
||||
} else {
|
||||
link.removeAttribute("aria-current");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 = "" } = {}) {
|
||||
const loadingBar = document.getElementById('loading-bar');
|
||||
|
||||
if (loadingBar) {
|
||||
@@ -43,6 +59,7 @@ async function goto(location, { push = true } = {}) {
|
||||
const [metadata, content] = await response.json();
|
||||
|
||||
document.dispatchEvent(new Event('beforenavigate'));
|
||||
hideNavNotice();
|
||||
|
||||
const root = document.getElementById("root");
|
||||
root.innerHTML = content;
|
||||
@@ -57,22 +74,25 @@ async function goto(location, { push = true } = {}) {
|
||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||
});
|
||||
|
||||
if (window.location.href.includes("#")) {
|
||||
const id = decodeURIComponent(window.location.hash.substring(1));
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView();
|
||||
const target = hash || (push ? "" : decodeURIComponent(window.location.hash.substring(1)));
|
||||
const targetEl = target ? document.getElementById(target) : null;
|
||||
if (targetEl) {
|
||||
revealTarget(targetEl);
|
||||
targetEl.scrollIntoView({ behavior: "instant", block: "start" });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
}
|
||||
|
||||
toggleMenu(true);
|
||||
markCurrentPage(location);
|
||||
document.querySelector("title").textContent = metadata["title"];
|
||||
if (push) {
|
||||
history.pushState(null, null, metadata["canonical"]);
|
||||
history.pushState(null, null, metadata["canonical"] + (hash ? "#" + hash : ""));
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Navigation failed:", err);
|
||||
showNavNotice("Couldn't load that page. Check your connection and try again.");
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout);
|
||||
if (loadingBar && loadingBar.classList.contains('active')) {
|
||||
@@ -89,19 +109,78 @@ async function goto(location, { push = true } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Featured project panels: collapsed to a short peek until expanded.
|
||||
function setFeatureExpanded(feature, expanded) {
|
||||
const clips = feature.querySelectorAll(".feature-clip");
|
||||
const btn = feature.querySelector(".feature-expand");
|
||||
if (expanded) {
|
||||
feature.classList.add("expanded");
|
||||
clips.forEach(function (el) {
|
||||
el.style.maxHeight = el.scrollHeight + "px";
|
||||
});
|
||||
// Once open, let the regions size themselves so late-loading content is never clipped.
|
||||
setTimeout(function () {
|
||||
if (!feature.classList.contains("expanded")) return;
|
||||
clips.forEach(function (el) {
|
||||
el.style.maxHeight = "none";
|
||||
});
|
||||
}, 400);
|
||||
} else {
|
||||
// Pin the current heights, force a reflow, then let CSS animate down to the peek.
|
||||
clips.forEach(function (el) {
|
||||
el.style.maxHeight = el.scrollHeight + "px";
|
||||
});
|
||||
void feature.offsetHeight;
|
||||
feature.classList.remove("expanded");
|
||||
clips.forEach(function (el) {
|
||||
el.style.maxHeight = "";
|
||||
});
|
||||
}
|
||||
if (btn) btn.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||
}
|
||||
|
||||
function toggleFeature(btn) {
|
||||
const feature = btn.closest(".feature");
|
||||
if (feature) setFeatureExpanded(feature, !feature.classList.contains("expanded"));
|
||||
}
|
||||
|
||||
// Expand a collapsed panel (or the one containing the target) before jumping to it.
|
||||
function revealTarget(el) {
|
||||
const feature = el.closest(".feature");
|
||||
if (feature && !feature.classList.contains("expanded")) {
|
||||
setFeatureExpanded(feature, true);
|
||||
feature.querySelectorAll(".feature-clip").forEach(function (el) {
|
||||
el.style.maxHeight = "none";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (!window.location.hash) return;
|
||||
const el = document.getElementById(decodeURIComponent(window.location.hash.substring(1)));
|
||||
if (el) {
|
||||
revealTarget(el);
|
||||
el.scrollIntoView({ behavior: "instant", block: "start" });
|
||||
}
|
||||
});
|
||||
|
||||
function backButton() {
|
||||
const path = window.location.pathname;
|
||||
goto(path.substring(1), { push: false });
|
||||
goto(path.substring(1) || "home", { push: false });
|
||||
}
|
||||
|
||||
function activeSkill(obj) {
|
||||
let skill = obj.closest(".skill");
|
||||
if (skill.classList.contains("activeSkill")) {
|
||||
skill.classList.remove("activeSkill");
|
||||
const btn = obj.closest('.skillname') || obj.querySelector?.('.skillname') || obj;
|
||||
if (btn && btn.setAttribute) btn.setAttribute("aria-expanded", "false");
|
||||
return;
|
||||
}
|
||||
while (skill) {
|
||||
skill.classList.add("activeSkill");
|
||||
const nameEl = skill.querySelector?.(':scope > .skillname') || skill.querySelector('.skillname');
|
||||
if (nameEl) nameEl.setAttribute("aria-expanded", "true");
|
||||
skill = skill.parentElement.closest(".skill");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,17 +19,19 @@ async function fetchStatus() {
|
||||
}
|
||||
|
||||
function updateStatusDisplay(data) {
|
||||
showStaleNotice(!!data.stale);
|
||||
|
||||
if (data.last_check) {
|
||||
const lastCheck = new Date(data.last_check);
|
||||
const lastUpdateEl = document.getElementById('lastUpdate');
|
||||
if (lastUpdateEl) lastUpdateEl.textContent = `Last checked: ${lastCheck.toLocaleString()}`;
|
||||
if (lastUpdateEl) lastUpdateEl.textContent = lastCheck.toLocaleString();
|
||||
}
|
||||
|
||||
if (data.next_check) {
|
||||
const nextCheckEl = document.getElementById('nextUpdate');
|
||||
if (nextCheckEl) {
|
||||
const nextCheck = new Date(data.next_check);
|
||||
nextCheckEl.textContent = `Next check: ${nextCheck.toLocaleString()}`;
|
||||
nextCheckEl.textContent = nextCheck.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +45,7 @@ function updateStatusDisplay(data) {
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.textContent = 'Refresh Now';
|
||||
refreshBtn.textContent = 'Refresh now';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +114,7 @@ function updateServiceCard(service) {
|
||||
formatUptime(service.uptime['7d'], '7d'),
|
||||
formatUptime(service.uptime['30d'], '30d'),
|
||||
formatUptime(service.uptime.all_time, 'All'),
|
||||
].join(' | ');
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
if (checksDisplay && service.total_checks !== undefined) {
|
||||
@@ -188,20 +190,42 @@ function showError(message) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'status-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'background: rgba(244, 67, 54, 0.2); color: #f44336; padding: 1em; margin: 1em 0; border-radius: 0.5em; text-align: center;';
|
||||
|
||||
const container = document.querySelector('.foregroundContent');
|
||||
|
||||
const container = document.querySelector('.page');
|
||||
if (container) {
|
||||
container.insertBefore(errorDiv, container.firstChild);
|
||||
setTimeout(function() { errorDiv.remove(); }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.textContent = 'Checking...';
|
||||
refreshBtn.textContent = 'Checking';
|
||||
}
|
||||
fetchStatus();
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
{
|
||||
"selection": [
|
||||
"The Rational Optimist",
|
||||
"The End of the World is Just the Beginning",
|
||||
"When to Rob a Bank",
|
||||
"Project Hail Mary",
|
||||
"Freakonomics",
|
||||
"The Accidental Superpower",
|
||||
"Verbal Judo",
|
||||
"Zero To One"
|
||||
"The Martian"
|
||||
],
|
||||
"books": {
|
||||
"Fooled By Randomness": {
|
||||
"filename": "fooledbyrandomness.jpg",
|
||||
"link": "https://www.amazon.com/Fooled-Randomness-Hidden-Chance-Markets-dp-B006Q7VYC4/dp/B006Q7VYC4/ref=dp_ob_title_bk",
|
||||
"review": "A lengthy compendium on probabilistic reasoning that helped kick off a curiosity of indefinite computation. There's more ancient philosophy than a book like this really needs but the occasional brazen punchline from the contemporary anecdotes make it bearable."
|
||||
"Project Hail Mary": {
|
||||
"filename": "hailmary.jpg",
|
||||
"link": "https://www.amazon.com/Project-Hail-Mary-Andy-Weir/dp/0593135202",
|
||||
"review": "This must be the first hard-sci-fi book I have read and I absolutely loved it. When I watched the movie I concluded that both executed on an inspiring story with an endearing tone but only the book held the creative practicality that really made it a favorite."
|
||||
},
|
||||
"The Martian": {
|
||||
"filename": "themartian.jpg",
|
||||
"link": "https://www.amazon.com/Martian-Andy-Weir/dp/0553418025",
|
||||
"review": "I picked this up after Project Hail Mary looking for more creative uses of hard scifi. On that account The Martian surpassed expectations - partially because the plot didn't have to contend with fantasy elements. I aspire to one day use a radioactive space heater as a hot tub power source, just as my hero, Mark Watney, once did."
|
||||
},
|
||||
"The Rational Optimist": {
|
||||
"filename": "ratOpt.jpg",
|
||||
@@ -39,6 +42,11 @@
|
||||
"link": "https://freakonomics.com/books/",
|
||||
"review": "More like the other Freakonomics books than I expected (cracked storytelling), which is still excellent, but I wished there was greater insights into seeing past conventional wisdom, which is what thinking like a freak means. Still a great book."
|
||||
},
|
||||
"Fooled By Randomness": {
|
||||
"filename": "fooledbyrandomness.jpg",
|
||||
"link": "https://www.amazon.com/Fooled-Randomness-Hidden-Chance-Markets-dp-B006Q7VYC4/dp/B006Q7VYC4/ref=dp_ob_title_bk",
|
||||
"review": "A lengthy compendium on probabilistic reasoning that helped kick off a curiosity of indefinite computation. There's more ancient philosophy than a book like this really needs but the occasional brazen punchline from the contemporary anecdotes make it bearable."
|
||||
},
|
||||
"The Tyranny of Metrics": {
|
||||
"filename": "TyrannyOfMetrics.jpg",
|
||||
"link": "https://www.amazon.com/Tyranny-Metrics-Jerry-Z-Muller/dp/0691174954",
|
||||
@@ -72,12 +80,12 @@
|
||||
"No, They Can't": {
|
||||
"filename": "no-they-cant.jpeg",
|
||||
"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": {
|
||||
"filename": "giveMeABreak.jpeg",
|
||||
"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": {
|
||||
"filename": "reignofterror.jpg",
|
||||
@@ -117,7 +125,7 @@
|
||||
"The Scout Mindset": {
|
||||
"filename": "scoutMindset.png",
|
||||
"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": {
|
||||
"filename": "verbalJudo.png",
|
||||
@@ -142,12 +150,7 @@
|
||||
"Where Good Ideas Come From": {
|
||||
"filename": "where-good-ideas-come-from.png",
|
||||
"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!)"
|
||||
},
|
||||
"Make Your Bed": {
|
||||
"filename": "makeYourBed.jpg",
|
||||
"link": "https://www.amazon.com/Make-Your-Bed-Little-Things/dp/1455570249",
|
||||
"review": "Something small to read on a rainy day or flight. Valuable advice condensed into personal stories that stretch beyond anecdotes."
|
||||
"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": {
|
||||
"filename": "12RulesForLife.jpg",
|
||||
@@ -163,6 +166,11 @@
|
||||
"filename": "HitchhikersGuideToTheGalaxy.jpeg",
|
||||
"link": "https://www.amazon.com/Hitchhikers-Guide-Galaxy-Douglas-Adams/dp/0345418913",
|
||||
"review": "It's alright. It felt like an aimless journey without defined boundaries that reveled in that fact for irony and wit points."
|
||||
},
|
||||
"The Adventures of Sherlock Holmes": {
|
||||
"filename": "sherlockholmes.jpg",
|
||||
"link": "https://www.amazon.com/Adventures-Sherlock-Holmes-Arthur-Conan/dp/0141034332",
|
||||
"review": "Had there not been half a dozen TV shows and movies about Sherlock Holmes all pulling from the same source material, I might have been intrigued. Instead, I felt like I was proofreading an old English fan rewrite of a book that I had already read. By today's standards, the outcomes were very telegraphed. Still enjoyed thinking through the deductions, though."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
src/static/json/features.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"homelab": {
|
||||
"title": "AI Homelab and Self Hosted Web Tooling",
|
||||
"tagline": "A self-hosted cluster serving Mixture-of-Experts models beyond their rated VRAM and independent web services",
|
||||
"body": "<p>I refined a method to pool GPUs from different vendors and generations into a single inference backend of roughly 20 GB over Vulkan. FreeToken, a bleeding-edge MoE inference server, fronts the cluster and handles the elastic inference, running Mixture-of-Experts models with far more total parameters than the cluster's VRAM would normally allow. My own scripts handle KV caching and system-prompt compaction, which is what makes the setup harness-capable.</p><p>The same rig trains as well as it serves, having refined Qwen SLMs and diffusion image models. All this is hosted alongside my personal web assets, like this website. I voluntarily subject myself to the maintenance of production-grade systems on every level of the tech stack to ensure that I survive the Big Tech apocalypse.</p>",
|
||||
"stack": ["FreeToken", "ComfyUI", "PEFT / TRL", "Docker", "Cloudflare", "Reverse Proxy"],
|
||||
"image": "photos/deepdives/homelab-dashboard.png",
|
||||
"caption": "Cluster dashboard during a long inference run."
|
||||
},
|
||||
"capstone": {
|
||||
"title": "Subatomic Particle Tracing and Anomaly Control",
|
||||
"tagline": "My Master's Capstone: Building a Diffusion Cloud Chamber and using stereo reconstruction to classify Muons and Alpha/Beta particles.",
|
||||
"body": "<p>My ongoing Data Science Capstone Project combines existing utilities for cheap quantum demonstrations and real world measuring techniques to create effective sensing of subatomic phenomena without breaking the bank or into the IAEA</p>",
|
||||
"stack": ["Ego"]
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"home": {
|
||||
"template": "home.html",
|
||||
"title": "Andrew Simonson - Portfolio Home",
|
||||
"description": "Andrew Simonson's Digital Portfolio home",
|
||||
"title": "Andrew Simonson - Data Scientist & Researcher",
|
||||
"description": "Andrew Simonson - Data Scientist at Ecolab, graduate student at RIT. Projects, research papers, and more.",
|
||||
"canonical": "/"
|
||||
},
|
||||
"status": {
|
||||
"template": "status.html",
|
||||
"title":"Andrew Simonson - Status Page",
|
||||
"title": "Andrew Simonson - Status Page",
|
||||
"description": "Status page for my services",
|
||||
"canonical": "/status"
|
||||
},
|
||||
"projects": {
|
||||
"template": "projects.html",
|
||||
"title": "Andrew Simonson - Projects",
|
||||
"description": "Recent projects by Andrew Simonson on his lovely portfolio website :)",
|
||||
"title": "Projects & Research - Andrew Simonson",
|
||||
"description": "Data science projects, geospatial analysis, research papers, and software experiments by Andrew Simonson.",
|
||||
"canonical": "/projects"
|
||||
},
|
||||
"books": {
|
||||
@@ -25,14 +25,46 @@
|
||||
},
|
||||
"duck": {
|
||||
"template": "duck.html",
|
||||
"title":"You've been ducked!",
|
||||
"title": "You've been ducked!",
|
||||
"description": "Face it, you've been ducked",
|
||||
"canonical": "/duck"
|
||||
},
|
||||
"certificates": {
|
||||
"template": "certs.html",
|
||||
"title": "Certificates and Awards",
|
||||
"description": "Certificates and Awards Listing",
|
||||
"canonical": "/certs"
|
||||
"about": {
|
||||
"template": "about.html",
|
||||
"title": "About, Timeline & Certifications - Andrew Simonson",
|
||||
"description": "Bio, career/education timeline, and certifications for Andrew Simonson - Data Scientist at Ecolab.",
|
||||
"canonical": "/about",
|
||||
"timeline": {
|
||||
"ecolab": {
|
||||
"title": "Data Scientist / Data Engineer — Ecolab",
|
||||
"date": "January 2024 – Present",
|
||||
"content": "<p class=\"timeitem-org\">Ecolab · Saint Paul, MN</p><ul><li>Primary model developer of RushReady from inception through multimillion-dollar product commercialization</li><li>Engineered near-real-time data platform with Delta Live Tables and automated MLOps lifecycle across QSR brands</li><li>Built explainable anomaly detection and brand-configurable recommendations, increasing speed of service by 20%</li><li>Represented Ecolab as liaison in Microsoft's 100-member AI accelerator program</li></ul>",
|
||||
"classes": "experience"
|
||||
},
|
||||
"rit_ms": {
|
||||
"title": "M.S. Data Science — RIT",
|
||||
"date": "Dec 2026 (expected)",
|
||||
"content": "<p class=\"timeitem-org\">Rochester Institute of Technology · Rochester, NY</p><ul><li>Focus on probability theory, statistical learning, and Bayesian methods</li><li>Capstone: Fall 2026</li></ul>",
|
||||
"classes": "education"
|
||||
},
|
||||
"dow_chemical": {
|
||||
"title": "Data Engineer — Dow Chemical",
|
||||
"date": "January 2023 – May 2023",
|
||||
"content": "<p class=\"timeitem-org\">Dow Chemical · Freeport, TX</p><ul><li>Independently built reactive chemistry analysis Flask app encoding adiabatic correction and exotherm-detection logic across 9 test types</li><li>Architected object-model abstraction, decoupling test-type parsers from report generation and replacing 4 legacy VBA tools</li><li>Product became the interdepartmental data standard, saving >1,020 hours (~0.65 FTE) annually</li></ul>",
|
||||
"classes": "experience"
|
||||
},
|
||||
"rit_bs": {
|
||||
"title": "B.S. Computer Science & Data Science — RIT",
|
||||
"date": "Dec 2024",
|
||||
"content": "<p class=\"timeitem-org\">Rochester Institute of Technology · Rochester, NY</p><ul><li>GPA 3.63 · Dean's List</li><li>Minor in International Relations</li><li>MicroMasters in Data Science – UC San Diego (edX)</li></ul>",
|
||||
"classes": "education"
|
||||
},
|
||||
"csh": {
|
||||
"title": "E-Board Member & Presenter — CSH",
|
||||
"date": "Aug 2021 – Present",
|
||||
"content": "<p class=\"timeitem-org\">Computer Science House · Rochester, NY</p><ul><li>Presented seminars on web scraping, analytics, and GIS</li><li>Built and maintained web services for 80+ members</li></ul>",
|
||||
"classes": "experience technical"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"links": [
|
||||
[
|
||||
"globe",
|
||||
"http://files.asimonson.com/u/AIcodeSmells.pdf",
|
||||
"https://files.asimonson.com/u/AIcodeSmells.pdf",
|
||||
"Paper"
|
||||
]
|
||||
]
|
||||
@@ -20,33 +20,33 @@
|
||||
"links": [
|
||||
[
|
||||
"globe",
|
||||
"http://files.asimonson.com/u/blanketTrust.pdf",
|
||||
"https://files.asimonson.com/u/blanketTrust.pdf",
|
||||
"Paper"
|
||||
]
|
||||
]
|
||||
},
|
||||
"PsyCom - Physical Combinatorics": {
|
||||
"PhysCom - Physical Combinatorics": {
|
||||
"status": "WIP",
|
||||
"classes": "programming",
|
||||
"content": "Experimental innovation engine operating on physical attributes and limitations of proven existing technologies with further AI review."
|
||||
},
|
||||
"Antietam-Conococheague Watershed Monitoring": {
|
||||
"status": "complete",
|
||||
"classes": "geospacial",
|
||||
"classes": "geospatial",
|
||||
"bgi": "watershedTemps.png",
|
||||
"content": "Live geospacial analysis of Maryland's Antietam and Conococheague sub-watersheds, monitoring water quality and temperatures through the summer months for governmental environment health review boards."
|
||||
"content": "Live geospatial analysis of Maryland's Antietam and Conococheague sub-watersheds, monitoring water quality and temperatures through the summer months for governmental environment health review boards."
|
||||
},
|
||||
"Automotive Brand Valuation Analysis": {
|
||||
"status": "complete",
|
||||
"classes": "programming",
|
||||
"bgi": "automotiveBrandAnalysis.png",
|
||||
"content": "Brand valuation analysis of the used car market, measuring value decay by mileage to extrapolate qualities such as percieved reliability and persistent value of luxury features."
|
||||
"content": "Brand valuation analysis of the used car market, measuring value decay by mileage to extrapolate qualities such as perceived reliability and persistent value of luxury features."
|
||||
},
|
||||
"RIT Hotspots": {
|
||||
"status": "incomplete",
|
||||
"classes": "pinned geospacial programming",
|
||||
"classes": "pinned geospatial programming",
|
||||
"bgi": "hotspotsrit.png",
|
||||
"content": "Live crowd migration map using RIT occupancy data. It seems RIT didn't like me exposing their surveilance state but since they didn't want to talk to me about it they instead changed the service response schema a few times. When that didn't stop me they just shut down the whole service. Nerds.",
|
||||
"content": "Live crowd migration map using RIT occupancy data. It seems RIT didn't like me exposing their surveillance state but since they didn't want to talk to me about it they instead changed the service response schema a few times. When that didn't stop me they just shut down the whole service. Nerds.",
|
||||
"links": [
|
||||
["github", "https://github.com/asimonson1125/hotspotsrit", "git repo"]
|
||||
]
|
||||
@@ -55,14 +55,14 @@
|
||||
"status": "complete",
|
||||
"classes": "pinned programming",
|
||||
"bgi": "calorimeterAnalysis.png",
|
||||
"content": "An analytical toolkit designed for reactive chemistry analysis, especially calorimetry. Works include automatic analysis, alerting unusual and dangerous results derived from a wide range of testing envrionments and equipment",
|
||||
"content": "An analytical toolkit designed for reactive chemistry analysis, especially calorimetry. Works include automatic analysis, alerting unusual and dangerous results derived from a wide range of testing environments and equipment",
|
||||
"links": []
|
||||
},
|
||||
"Geography of Alternative Energy": {
|
||||
"status": "complete",
|
||||
"classes": "pinned geospacial",
|
||||
"classes": "pinned geospatial",
|
||||
"bgi": "energyGeography.png",
|
||||
"content": "An ArcGIS geospacial analysis comparing the difference in effectiveness of wind, solar, and geothermal energy across the continental 48 United States.",
|
||||
"content": "An ArcGIS geospatial analysis comparing the difference in effectiveness of wind, solar, and geothermal energy across the continental 48 United States.",
|
||||
"links": [
|
||||
[
|
||||
"globe",
|
||||
@@ -83,7 +83,7 @@
|
||||
"Portfolio Website": {
|
||||
"status": "complete",
|
||||
"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": [
|
||||
["globe", "https://asimonson.com", "Homepage"],
|
||||
[
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
{
|
||||
"Co-op @ Dow Chemical": {
|
||||
"Data Scientist @ Ecolab": {
|
||||
"classes": "pinned experience technical",
|
||||
"date": "01/2024 - Present",
|
||||
"content": "Primary model developer of RushReady from inception through multimillion-dollar product commercialization. Engineered near-real-time data platform with Delta Live Tables and automated MLOps lifecycle. Built explainable anomaly detection, increasing speed of service by 20%. Represented Ecolab in Microsoft's 100-member AI accelerator program."
|
||||
},
|
||||
"Data Scientist / Data Engineer": {
|
||||
"classes": "pinned experience technical",
|
||||
"date": "01/2023 - 05/2023",
|
||||
"content": "Spring 2023 Semester Co-op under Dow Chemical's Global Reactive Chemicals team in Analytical Sciences. Responsibilities included management of chemical compatability data and tool creation for parsing, generating, and submitting reports."
|
||||
"content": "Co-op under Dow Chemical's Global Reactive Chemicals team in Analytical Sciences. Independently built reactive chemistry analysis Flask app encoding adiabatic correction and exotherm-detection logic. Replaced 4 legacy VBA tools with an object-model abstraction architecture. Product became the interdepartmental data standard, saving >1,020 hours (~0.65 FTE) annually."
|
||||
},
|
||||
"Rochester Institute of Technology — M.S.": {
|
||||
"classes": "pinned education technical",
|
||||
"date": "01/2023 - 12/2026",
|
||||
"content": "Data Science M.S. with dual cluster in Data Science and AI. Capstone Fall 2026. Certified by Databricks in ML Model Deployment, Retrieval Agents, and MLOps. Certified by Microsoft in Data Analysis Career Essentials and Docker Foundations."
|
||||
},
|
||||
"Rochester Institute of Technology — B.S.": {
|
||||
"classes": "pinned education technical",
|
||||
"date": "08/2021 - 12/2024",
|
||||
"content": "Computer Science B.S. (GPA 3.63, Dean's List) with a minor in International Relations. Active member of Computer Science House — built and maintained web services for 80+ members, presented seminars on web scraping, analytics, and GIS."
|
||||
},
|
||||
"UCSanDiegoX Data Science MicroMasters": {
|
||||
"classes": "education technical",
|
||||
"date": "2024",
|
||||
"content": "Completed MicroMasters program in Data Science through UC San Diego on edX."
|
||||
},
|
||||
"Started Portfolio": {
|
||||
"classes": "",
|
||||
"date": "08/26/2021",
|
||||
"content": "I started building this website on this day. I wish I could say I was farther along than I am."
|
||||
},
|
||||
"Rochester Institute of Technology": {
|
||||
"classes": "pinned education technical",
|
||||
"date": "08/2021 - 12/2024",
|
||||
"content": "Studying in Rochester Institute of Technology's Computer Science BS program with a minor in International Relations."
|
||||
"Human vs AI Code Smell Study": {
|
||||
"classes": "technical",
|
||||
"date": "2024 - 2025",
|
||||
"content": "Analyzed 85K+ code definitions comparing AI vs human code maintainability via static analysis and non-parametric statistics. Found AI code carries 3-4x more structural debt invisible to reviewers."
|
||||
},
|
||||
"Generalized Trust Review": {
|
||||
"classes": "technical",
|
||||
"date": "2023 - 2024",
|
||||
"content": "Cross-national correlation analysis of media and science trust across 128 countries with subgroup OLS regression. Found trust transfer attenuates 5.7x in low press-freedom regimes."
|
||||
},
|
||||
"Antietam Watershed Monitoring": {
|
||||
"classes": "technical",
|
||||
"date": "2023",
|
||||
"content": "ArcGIS choropleth dashboard monitoring stream temps and biotic indices across Upper Potomac sub-watersheds. Flags exceedances of healthy stream conditions for state environmental review."
|
||||
},
|
||||
"Hackathon Awards": {
|
||||
"classes": "technical",
|
||||
"date": "2023 - 2024",
|
||||
"content": "CSHacks - Best Use of AI (Paychex). Tiger Games - Social Impact Winner. HACK.COMS '24 - Accessibility Winner."
|
||||
},
|
||||
"Pretzel & Pizza Creations": {
|
||||
"classes": "experience",
|
||||
@@ -27,11 +62,11 @@
|
||||
"Boonsboro High School": {
|
||||
"classes": "education",
|
||||
"date": "09/2016 - 06/2021",
|
||||
"content": "Graduated high school with highest honors.\nMember of National Honor Society, Academic Team County Champions. Participated in Physics Olympics, Robotics Club, and scored at state championships in Cross Country and Track and Field (4x800, 800)."
|
||||
"content": "Graduated high school with highest honors. Member of National Honor Society, Academic Team County Champions. Participated in Physics Olympics, Robotics Club, and scored at state championships in Cross Country and Track and Field (4x800, 800)."
|
||||
},
|
||||
"Vex Robotics Team Lead/Club Preisdent": {
|
||||
"Vex Robotics Team Lead/Club President": {
|
||||
"classes": "technical",
|
||||
"date": "10/2015 - 04/2021",
|
||||
"content": "Led 5 teams through middle and high school to VEX Robotics Competitions, elevating Boonsboro from county group-stage elimination to its first state championship participation. Reorganized club and set up its first interface with the community + sponsors"
|
||||
"content": "Led 5 teams through middle and high school to VEX Robotics Competitions, elevating Boonsboro from county group-stage elimination to its first state championship participation. Reorganized club and set up its first interface with the community + sponsors."
|
||||
}
|
||||
}
|
||||
|
||||
BIN
src/static/photos/books/hailmary.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
src/static/photos/books/sherlockholmes.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/static/photos/books/themartian.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src/static/photos/deepdives/homelab-dashboard.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
src/static/photos/extradimensional.avif
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
src/static/photos/omnidimensional.avif
Normal file
|
After Width: | Height: | Size: 572 KiB |
BIN
src/static/photos/test.png
Normal file
|
After Width: | Height: | Size: 649 KiB |
@@ -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"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<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/Resume</loc>
|
||||
<loc>https://asimonson.com/duck</loc>
|
||||
<lastmod>2026-09-22</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://asimonson.com/books</loc>
|
||||
<lastmod>2026-09-22</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<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>
|
||||
</urlset>
|
||||
132
src/templates/about.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{% block content %}
|
||||
<div class="page">
|
||||
<header class="page-head">
|
||||
<h1 class="display">About</h1>
|
||||
<p class="lede">
|
||||
I'm Andrew Simonson, a data scientist at Ecolab and a graduate data
|
||||
science student at Rochester Institute of Technology, pursuing an M.S.
|
||||
in Data Science after finishing a B.S. in Computer Science with a
|
||||
minor in international relations and a focus on probability theory.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="prose about-bio">
|
||||
<p>
|
||||
At work I build the models and the pipelines behind them. At home I run
|
||||
the servers. In between, I get bored and put things on this website,
|
||||
self-built and self-hosted as a sandbox for
|
||||
webdev experiments. This is what unprofessional development looks like.
|
||||
</p>
|
||||
<p>
|
||||
There is also a <a href="/resume">résumé</a> for paper-minded people.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<section class="section" aria-labelledby="skills-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="skills-heading">Skills</h2>
|
||||
<p class="section-note">Click a group to expand it</p>
|
||||
</div>
|
||||
{% from 'partials/skills.html' import skills %}
|
||||
{{ skills(var['skillList']) }}
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="certs-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="certs-heading">Certifications</h2>
|
||||
<p class="section-note">
|
||||
Full list on <a href="https://www.linkedin.com/in/simonsonandrew/details/certifications/" rel="noopener noreferrer">LinkedIn</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="cert-group">
|
||||
<h3>Data Science MicroMasters</h3>
|
||||
<p class="cert-provider">UC San Diego on edX ·
|
||||
<a href="https://credentials.edx.org/credentials/4b7e78dca8154c0d88ca9abc5aedb4ac" rel="noopener noreferrer">program certificate</a>
|
||||
</p>
|
||||
<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/f29d0e65fc024c6e95121619e329a286" rel="noopener noreferrer">Probability and Statistics in Data Science using Python</a> <span class="cert-code">DSE210x</span></li>
|
||||
<li><a href="https://courses.edx.org/certificates/cccc2bd2ed61470e8492d6da1be530c5" rel="noopener noreferrer">Machine Learning Fundamentals</a> <span class="cert-code">DSE220x</span></li>
|
||||
<li><a href="https://courses.edx.org/certificates/4dfd6563a1f84caaa8922a02a5125f29" rel="noopener noreferrer">Big Data Analytics Using Spark</a> <span class="cert-code">DSE230x</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="cert-group">
|
||||
<h3>Independent</h3>
|
||||
<ul class="cert-list">
|
||||
<li><a href="https://files.asimonson.com/u/2398_3_1303226_1776777828_Databricks%20-%20Generic.pdf" rel="noopener noreferrer">Machine Learning Model Deployment</a> <span class="cert-code">Databricks</span></li>
|
||||
<li><a href="https://files.asimonson.com/u/2662_3_1303226_1772561098_Databricks%20-%20Generic.pdf" rel="noopener noreferrer">Building Retrieval Agents on Databricks</a> <span class="cert-code">Databricks</span></li>
|
||||
<li><a href="https://files.asimonson.com/u/2403_3_1303226_1765822061_Databricks%20-%20Generic.pdf" rel="noopener noreferrer">Machine Learning Operations</a> <span class="cert-code">Databricks</span></li>
|
||||
<li><a href="https://www.linkedin.com/learning/certificates/2cb69378c606fec5a6f3a107b99a896862db392b7a3692f71a6b53af5d5545c5" rel="noopener noreferrer">Career Essentials in Data Analysis</a> <span class="cert-code">Microsoft</span></li>
|
||||
<li><a href="https://www.linkedin.com/learning/certificates/7facc28a13405134b3b7fa785303e9b1cf697f32d67f759e89960fbdc8a044d9" rel="noopener noreferrer">Career Essentials in GitHub</a> <span class="cert-code">GitHub</span></li>
|
||||
<li><a href="https://www.linkedin.com/learning/certificates/7b952323152e258ca468c33ddc9ebcf3c55036f58a5cfb3fb9c1410da655aaa5" rel="noopener noreferrer">Docker Foundations</a> <span class="cert-code">Docker</span></li>
|
||||
<li><a href="https://www.linkedin.com/learning/certificates/7017147ac73af5bc26fdab9b3c43671fb8105a0de59d4689d5f0f71c549c150f" rel="noopener noreferrer">Data Science Foundations: Fundamentals</a> <span class="cert-code">LinkedIn</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="cert-group">
|
||||
<h3>Entrepreneurship</h3>
|
||||
<p class="cert-provider">Rochester Institute of Technology</p>
|
||||
<ul class="cert-list">
|
||||
<li><a href="https://files.asimonson.com/u/designThinkingCert.pdf" rel="noopener noreferrer">Design Thinking</a></li>
|
||||
<li><a href="https://files.asimonson.com/u/ideationCert.pdf" rel="noopener noreferrer">Ideation</a></li>
|
||||
<li><a href="https://files.asimonson.com/u/toolsForInnovatorsCert.pdf" rel="noopener noreferrer">Tools for Innovators</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section" aria-labelledby="timeline-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="timeline-heading">Timeline</h2>
|
||||
<p class="section-note filters" role="group" aria-label="Filter timeline entries">
|
||||
<button class="filter-btn" data-filter="all" aria-pressed="true" type="button">All</button>
|
||||
<button class="filter-btn" data-filter="experience" aria-pressed="false" type="button">Experience</button>
|
||||
<button class="filter-btn" data-filter="education" aria-pressed="false" type="button">Education</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol class="timeline" id="timeline">
|
||||
{% for name, item in var['timeline'].items() %}
|
||||
<li class="timeitem" data-categories="{{ item.classes }}">
|
||||
<p class="timeitem-date">{{ item.date }}</p>
|
||||
<div class="timeitem-body">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<div class="timeitem-content">{{ item.content | safe }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<p class="timeline-empty" id="timelineEmpty" hidden>Nothing in this category yet.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const buttons = document.querySelectorAll('.filter-btn[data-filter]');
|
||||
const items = document.querySelectorAll('.timeitem');
|
||||
const empty = document.getElementById('timelineEmpty');
|
||||
|
||||
function applyFilter(filter) {
|
||||
let shown = 0;
|
||||
items.forEach(function(item) {
|
||||
const cats = item.getAttribute('data-categories') || '';
|
||||
const matches = filter === 'all' || cats.split(' ').indexOf(filter) !== -1;
|
||||
item.hidden = !matches;
|
||||
if (matches) shown++;
|
||||
});
|
||||
if (empty) empty.hidden = shown > 0;
|
||||
}
|
||||
|
||||
buttons.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
buttons.forEach(function(b) { b.setAttribute('aria-pressed', 'false'); });
|
||||
this.setAttribute('aria-pressed', 'true');
|
||||
applyFilter(this.getAttribute('data-filter'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,46 +1,58 @@
|
||||
{% block content %}
|
||||
<div class="foreground"></div>
|
||||
<div class="foregroundContent">
|
||||
<h2>"You can't judge a book by its cover but you can judge a person by their bookshelf" - Me, today.</h2>
|
||||
<h3>My Favorites</h3>
|
||||
<div class='flex wrap boxed'>
|
||||
<div class="page">
|
||||
<header class="page-head">
|
||||
<h1 class="display">Bookshelf</h1>
|
||||
<p class="lede">
|
||||
You can't judge a book by its cover, but you can judge a person by their
|
||||
bookshelf. Short reviews of what I've read, favorites first.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="section" aria-labelledby="fav-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="fav-heading">Favorites</h2>
|
||||
</div>
|
||||
<ul class="cover-row" aria-label="Favorite books">
|
||||
{% for i in var.books.selection %}
|
||||
<div>
|
||||
<a href="#'{{i}}'">
|
||||
<li>
|
||||
<a href="#'{{ i }}'" title="{{ i }}">
|
||||
<img
|
||||
class="bookcover"
|
||||
alt="{{i}} cover"
|
||||
src="{{ url_for('static', filename=('photos/books/' + var.books.books[i].filename))}}"
|
||||
alt="{{ i }}"
|
||||
src="{{ url_for('static', filename=('photos/books/' + var.books.books[i].filename)) }}"
|
||||
loading="lazy"
|
||||
width="80"
|
||||
height="120"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="reviews-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="reviews-heading">All reviews</h2>
|
||||
<p class="section-note">{{ var.books.books | length }} books</p>
|
||||
</div>
|
||||
<h3>All Reviews</h3>
|
||||
<p>Hover to reveal review<br />Read to reveal KNOWLEDGE</p>
|
||||
<div class="booklist flex wrap">
|
||||
<ul class="review-list">
|
||||
{% for i in var.books.books %}
|
||||
<div id="'{{i}}'" class="bookReview">
|
||||
<div class='fullHeight flex'>
|
||||
<div>
|
||||
<div class='heightBox'>
|
||||
<li id="'{{ i }}'" class="review">
|
||||
<img
|
||||
class="bookcover"
|
||||
alt="{{i}} cover"
|
||||
src="{{ url_for('static', filename=('photos/books/' + var.books.books[i].filename))}}"
|
||||
class="review-cover"
|
||||
alt=""
|
||||
src="{{ url_for('static', filename=('photos/books/' + var.books.books[i].filename)) }}"
|
||||
loading="lazy"
|
||||
width="80"
|
||||
height="120"
|
||||
/>
|
||||
<div class="review-body">
|
||||
<h3>{{ i }}</h3>
|
||||
<p>{{ var.books.books[i].review }}</p>
|
||||
<p class="review-link"><a href="{{ var.books.books[i].link }}" rel="noopener noreferrer">Where to find it</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class='vFlex emPad spaceBetween'>
|
||||
<div>
|
||||
<h4 class='nomargin'>{{ i }}</h4>
|
||||
<p class='nomargin'>{{ var.books.books[i].review }}</p>
|
||||
</div>
|
||||
<a href="{{ var.books.books[i].link }}"><p>Book Source</p></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
{% block content %}
|
||||
<div class="foreground"></div>
|
||||
<div class="foregroundContent">
|
||||
<h1>Certifications</h1>
|
||||
<p class="page-subtitle">
|
||||
Comprehensive list verifiable on
|
||||
<a href="https://www.linkedin.com/in/simonsonandrew/details/certifications/">LinkedIn</a>
|
||||
</p>
|
||||
<p>Computer Science BS and Data Science MS from Rochester Institute of Technology</p>
|
||||
|
||||
<div class="boxed cert-group">
|
||||
<p class="cert-group-provider">UCSanDiegoX · edX</p>
|
||||
<h2 class="concentratedHead">Data Science MicroMasters Program</h2>
|
||||
<a href="http://credentials.edx.org/credentials/4b7e78dca8154c0d88ca9abc5aedb4ac" class="cert-program-badge">
|
||||
View Program Certificate ›
|
||||
</a>
|
||||
<ul class="cert-list">
|
||||
<li><a href="https://courses.edx.org/certificates/b6deccc56e5344ae84cb55f9ad81fd79" class="cert-item">DSE200x — Python for Data Science</a></li>
|
||||
<li><a href="https://courses.edx.org/certificates/f29d0e65fc024c6e95121619e329a286" class="cert-item">DSE210x — Probability and Statistics in Data Science using Python</a></li>
|
||||
<li><a href="https://courses.edx.org/certificates/cccc2bd2ed61470e8492d6da1be530c5" class="cert-item">DSE220x — Machine Learning Fundamentals</a></li>
|
||||
<li><a href="https://courses.edx.org/certificates/4dfd6563a1f84caaa8922a02a5125f29" class="cert-item">DSE230x — Big Data Analytics Using Spark</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="cert-grid">
|
||||
<div class="boxed cert-group">
|
||||
<h3 class="concentratedHead">One-Off Courses</h3>
|
||||
<ul class="cert-list">
|
||||
<li><a href="https://files.asimonson.com/u/2398_3_1303226_1776777828_Databricks%20-%20Generic.pdf" class="cert-item">Machine Learning Model Deployment by Databricks</a></li>
|
||||
<li><a href="https://files.asimonson.com/u/2662_3_1303226_1772561098_Databricks%20-%20Generic.pdf" class="cert-item">Building Retrieval Agents On Databricks</a></li>
|
||||
<li><a href="https://files.asimonson.com/u/2403_3_1303226_1765822061_Databricks%20-%20Generic.pdf" class="cert-item">Machine Learning Operations by Databricks</a></li>
|
||||
<li><a href="https://www.linkedin.com/learning/certificates/2cb69378c606fec5a6f3a107b99a896862db392b7a3692f71a6b53af5d5545c5" class="cert-item">Career Essentials in Data Analysis by Microsoft</a></li>
|
||||
<li><a href="https://www.linkedin.com/learning/certificates/7facc28a13405134b3b7fa785303e9b1cf697f32d67f759e89960fbdc8a044d9" class="cert-item">Career Essentials in GitHub Professional Certificate</a></li>
|
||||
<li><a href="https://www.linkedin.com/learning/certificates/7b952323152e258ca468c33ddc9ebcf3c55036f58a5cfb3fb9c1410da655aaa5" class="cert-item">Docker Foundations Professional Certificate</a></li>
|
||||
<li><a href="https://www.linkedin.com/learning/certificates/7017147ac73af5bc26fdab9b3c43671fb8105a0de59d4689d5f0f71c549c150f" class="cert-item">Data Science Foundations: Fundamentals</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="boxed cert-group">
|
||||
<p class="cert-group-provider">Rochester Institute of Technology</p>
|
||||
<h3 class="concentratedHead">Entrepreneurial Certifications</h3>
|
||||
<ul class="cert-list">
|
||||
<li><a href="https://files.asimonson.com/u/designThinkingCert.pdf" class="cert-item">Design Thinking Certification</a></li>
|
||||
<li><a href="https://files.asimonson.com/u/ideationCert.pdf" class="cert-item">Ideation Certification</a></li>
|
||||
<li><a href="https://files.asimonson.com/u/toolsForInnovatorsCert.pdf" class="cert-item">Tools for Innovators Certification</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,11 +1,12 @@
|
||||
{% block content %}
|
||||
<div class="foreground"></div>
|
||||
<div class="foregroundContent">
|
||||
<h1>What the duck?</h1>
|
||||
<div class="page">
|
||||
<header class="page-head">
|
||||
<h1 class="display">What the duck?</h1>
|
||||
</header>
|
||||
<img
|
||||
class="duck"
|
||||
alt="mega spinney duck"
|
||||
src="{{ url_for('static', filename='photos/gifs/duck-spinning.gif') }}"
|
||||
style="max-width: calc(100% - 2em);"
|
||||
/>
|
||||
</div>
|
||||
<!-- BONUS DUCKS! -->
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
{% block content %}
|
||||
<div class="foreground homeground"></div>
|
||||
<div class="fPage">
|
||||
<div class="heightBox">
|
||||
<div class="neonBox">
|
||||
<h1 class="neon">ERROR {{error}}</h1>
|
||||
<br /><br />
|
||||
<h3 class="neon">{{message}}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
href="{{ url_for('static', filename='icons/withBackground.svg') }}"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="theme-color" content="#141414" />
|
||||
|
||||
<!-- Resource hints for performance -->
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
|
||||
<meta name="description" content="{{ var['description'] }}" />
|
||||
<meta property="og:title" content="Andrew Simonson" />
|
||||
<meta name="og:description" content="{{ var['description'] }}" />
|
||||
<meta property="og:description" content="{{ var['description'] }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:image"
|
||||
@@ -29,9 +28,32 @@
|
||||
property="twitter:image"
|
||||
content="{{ url_for('static', filename='icons/rasterLogoCircle.png') }}"
|
||||
/>
|
||||
<meta name="twitter:image:alt" content="some example picture idk" />
|
||||
<meta name="twitter:image:alt" content="Andrew Simonson's logo" />
|
||||
<meta name="twitter:site" content="@asimonson1125" />
|
||||
|
||||
<!-- Structured data for SEO -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "Andrew Simonson",
|
||||
"url": "https://asimonson.com",
|
||||
"sameAs": [
|
||||
"https://github.com/asimonson1125",
|
||||
"https://www.linkedin.com/in/simonsonandrew"
|
||||
],
|
||||
"jobTitle": "Data Scientist",
|
||||
"worksFor": {
|
||||
"@type": "Organization",
|
||||
"name": "Ecolab"
|
||||
},
|
||||
"alumniOf": {
|
||||
"@type": "CollegeOrUniversity",
|
||||
"name": "Rochester Institute of Technology"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Global site tag (gtag.js) - Google Analytics (deferred for performance) -->
|
||||
<script
|
||||
async
|
||||
@@ -52,49 +74,54 @@
|
||||
/>
|
||||
<link rel="canonical" href="{{ request.url_root | trim('/') }}{{ var['canonical'] }}" />
|
||||
<script defer src="{{ url_for('static', filename='js/responsive.js') }}"></script>
|
||||
{# <script src="{{ url_for('static', filename='js/chessbed.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>
|
||||
</head>
|
||||
{% block header %}
|
||||
<body onpopstate="backButton()">
|
||||
<div id="loading-bar"></div>
|
||||
<a class="skip-link" href="#root">Skip to content</a>
|
||||
<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>
|
||||
<main id='map'></main>
|
||||
<div id="contentStuffer">
|
||||
<div class="header">
|
||||
<div id="name-container" onclick="goto('home')">
|
||||
<div class="line name">
|
||||
<span class="textGrad">{# Andrew Simonoson #}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src="{{ url_for('static', filename='icons/menu.svg')}}"
|
||||
alt="menu"
|
||||
<div id="bg" aria-hidden="true"></div>
|
||||
<div class="site">
|
||||
<header class="site-header" id="siteHeader">
|
||||
<a class="brand" href="/" data-page="home" onclick="goto('home'); return false;" aria-label="Andrew Simonson, home">
|
||||
<img class="brand-mark" src="{{ url_for('static', filename='icons/neonfinal3.svg') }}" alt="" width="30" height="30" />
|
||||
<span class="brand-name">Andrew Simonson</span>
|
||||
</a>
|
||||
<button
|
||||
id="menu"
|
||||
onClick="toggleMenu()"
|
||||
/>
|
||||
<div class="navControl">
|
||||
<div class="navBar">
|
||||
<div onClick="goto('home')" class="navElement">
|
||||
<p>Home</p>
|
||||
</div>
|
||||
<div onclick="goto('status')" class="navElement">
|
||||
<p>Status</p>
|
||||
</div>
|
||||
<div onclick="goto('projects')" class="navElement">
|
||||
<p>Work</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
class="menu-toggle"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="navBar"
|
||||
onclick="toggleMenu()"
|
||||
>Menu</button>
|
||||
<nav id="navBar" class="site-nav" aria-label="Main navigation">
|
||||
<a href="/" data-page="home" onclick="goto('home'); return false;" {% if var['id'] == 'home' %}aria-current="page"{% endif %}>Home</a>
|
||||
<a href="/projects" data-page="projects" onclick="goto('projects'); return false;" {% if var['id'] == 'projects' %}aria-current="page"{% endif %}>Work</a>
|
||||
<a href="/about" data-page="about" onclick="goto('about'); return false;" {% if var['id'] == 'about' %}aria-current="page"{% endif %}>About</a>
|
||||
<a href="/status" data-page="status" onclick="goto('status'); return false;" {% if var['id'] == 'status' %}aria-current="page"{% endif %}>Status</a>
|
||||
</nav>
|
||||
</header>
|
||||
{% endblock %} {% block content%}
|
||||
<div id="root">{% include var['template'] %}</div>
|
||||
<main id="root">{% include var['template'] %}</main>
|
||||
{% endblock %} {% block footer %}
|
||||
<div id="footerSpacer"></div>
|
||||
<div class="footer">{% include 'partials/socials.html' %}</div>
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<p class="footer-name">Andrew Simonson</p>
|
||||
<ul class="footer-links">
|
||||
<li><a href="https://github.com/asimonson1125" rel="noopener noreferrer">GitHub</a></li>
|
||||
<li><a href="https://www.linkedin.com/in/simonsonandrew/" rel="noopener noreferrer">LinkedIn</a></li>
|
||||
<li><a href="mailto:asimonson1125@gmail.com">Email</a></li>
|
||||
<li><a href="/resume">Résumé</a></li>
|
||||
<li><a href="/books" onclick="goto('books'); return false;">Bookshelf</a></li>
|
||||
<li><a href="https://github.com/asimonson1125/asimonson1125.github.io" rel="noopener noreferrer">Source</a></li>
|
||||
</ul>
|
||||
<p class="footer-note">Self-hosted. Uptime on the <a href="/status" onclick="goto('status'); return false;">status page</a>.</p>
|
||||
</div>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -1,62 +1,125 @@
|
||||
{% block content %} {% macro nameplate() %}
|
||||
<div>
|
||||
<h1 id="homeName" class="textGrad">oh no.</h1>
|
||||
<h2>I did not plan for visitors.</h2>
|
||||
|
||||
<br /> <hr> <br />
|
||||
|
||||
<div class="flex vertOnMobile">
|
||||
<div>
|
||||
<img
|
||||
src="{{ url_for('static', filename='photos/extradimensionalSq.avif') }}"
|
||||
id="homeIcon"
|
||||
fetchpriority=high
|
||||
alt="Biblically Accurate Seraphim"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="foreground homeground"></div>
|
||||
<div class="relative">
|
||||
<div id="nametag" class="flex" data-aos="fade-up">{{ nameplate() }}</div>
|
||||
<!--<INSERT SMALL BANNER HERE FOR PROJECT IMAGECARD CAROUSEL>-->
|
||||
<div id="desktopSpacer"></div>
|
||||
<div class="homeSubContent">
|
||||
<img class='blinkies' alt='Pepsi Addict' src="{{ url_for('static', filename='photos/blinkies/pepsiaddict.gif') }}" loading="lazy" />
|
||||
<img class='blinkies' alt='Secret Message' src="{{ url_for('static', filename='photos/blinkies/tooclose.gif') }}" loading="lazy" />
|
||||
<img class='blinkies' alt="They took my blood but it wasn't DNA, it was USA" src="{{ url_for('static', filename='photos/blinkies/usa.gif') }}" loading="lazy" />
|
||||
<img class='blinkies' alt='Bob the Builder gif' src="{{ url_for('static', filename='photos/blinkies/bobthebuilder.gif') }}" loading="lazy" />
|
||||
<div>
|
||||
<br />
|
||||
<strong> You've reached the website for Andrew Simonson's digital shenanigans.</strong>
|
||||
<h3>Now What?</h3>
|
||||
<p>
|
||||
Go back and find the link that I originally shared. Or poke around. Be your own person.</br>
|
||||
I'll grant myself some titles while I'm at it:
|
||||
{% block content %}
|
||||
<section class="hero" aria-labelledby="homeName">
|
||||
<div class="hero-inner">
|
||||
<h1 id="homeName" class="display">Andrew Simonson</h1>
|
||||
<p class="lede">
|
||||
Data scientist at Ecolab and a graduate student at RIT.
|
||||
I build end-to-end decision machines: from data pipelines that process scale, to epistemic models which understand, to user interfaces that convey.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Wicked Wizard of the West</li>
|
||||
<li>Enemy of Node.js, Hater of Bloat</li>
|
||||
<li>Load-Bearing Coconut</li>
|
||||
<li>Creator and Harnesser of Energy</li>
|
||||
<p class="hero-links">
|
||||
<a href="/projects" onclick="goto('projects'); return false;">Work</a>
|
||||
<a href="/about" onclick="goto('about'); return false;">About</a>
|
||||
<a href="/resume">Résumé</a>
|
||||
<a href="https://github.com/asimonson1125" rel="noopener noreferrer">GitHub</a>
|
||||
<a href="https://www.linkedin.com/in/simonsonandrew/" rel="noopener noreferrer">LinkedIn</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<section class="section" aria-labelledby="now-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="now-heading">Now</h2>
|
||||
<p class="section-note">Today</p>
|
||||
</div>
|
||||
<dl class="now-list">
|
||||
<div class="now-item">
|
||||
<dt>At work</dt>
|
||||
<dd>
|
||||
Primary model developer on RushReady at Ecolab: explainable anomaly
|
||||
detection, plus brand-configurable recommendations
|
||||
for quick-service restaurants, all running on a near-real-time Delta
|
||||
Live Tables platform.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="now-item">
|
||||
<dt>At school</dt>
|
||||
<dd>
|
||||
M.S. in Data Science at Rochester Institute of Technology, focused on
|
||||
probability theory, statistical learning, and Bayesian methods.
|
||||
Capstone in fall 2026.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="now-item">
|
||||
<dt>At home</dt>
|
||||
<dd>
|
||||
Running a <a href="/projects#feature-homelab" onclick="goto('projects', {hash: 'feature-homelab'}); return false;">two-GPU homelab</a>
|
||||
that serves Mixture-of-Experts models to coding agents over local
|
||||
endpoints, and using the same rig to fine-tune small models.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="now-item">
|
||||
<dt>On the shelf</dt>
|
||||
<dd>
|
||||
<ul class="shelf-row" aria-label="A few favorite books">
|
||||
{% for title in var.books.selection[:5] %}
|
||||
{% set book = var.books.books[title] %}
|
||||
<li>
|
||||
<a href="/books#'{{ title }}'" onclick="goto('books', {hash: "'{{ title }}'"}); return false;" title="{{ title }}">
|
||||
<img
|
||||
src="{{ url_for('static', filename='photos/books/' + book.filename) }}"
|
||||
alt="{{ title }}"
|
||||
loading="lazy"
|
||||
width="60"
|
||||
height="90"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a class="shelf-more" href="/books" onclick="goto('books'); return false;">All reviews</a>
|
||||
</dd>
|
||||
</div>
|
||||
<br />
|
||||
{#
|
||||
<div id="aboutCards" class="flex">
|
||||
<div class="chess">
|
||||
{% from 'partials/chess.html' import chess %} {{
|
||||
chess('asimonson1125') }}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="selected-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="selected-heading">Selected work</h2>
|
||||
<p class="section-note"><a href="/projects" onclick="goto('projects'); return false;">Everything</a></p>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<img height='150px' alt='stabby' src="{{ url_for('static', filename='photos/electricityStabby.png') }}" loading="lazy" />
|
||||
<ol class="selected-list">
|
||||
{% for id, f in var.features.items() %}
|
||||
<li>
|
||||
<a href="/projects#feature-{{ id }}" onclick="goto('projects', {hash: 'feature-{{ id }}'}); return false;">{{ f.title }}</a>
|
||||
<span>{{ f.summary or f.tagline }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="site-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="site-heading">What now?</h2>
|
||||
</div>
|
||||
<br />
|
||||
<div class="colophon">
|
||||
<div class="colophon-text">
|
||||
<p>
|
||||
I wasn't really expecting anyone to read this far.
|
||||
If you're looking for shared resources you've taken a wrong turn.
|
||||
</p>
|
||||
<p>
|
||||
From here, we'll rekindle the future.
|
||||
</p>
|
||||
<p class="blinkies" aria-label="Retro web badges">
|
||||
<img alt="Pepsi Addict" src="{{ url_for('static', filename='photos/blinkies/pepsiaddict.gif') }}" loading="lazy" width="150" height="20" />
|
||||
<img alt="If you can read this you are too close to the monitor" src="{{ url_for('static', filename='photos/blinkies/tooclose.gif') }}" loading="lazy" width="150" height="20" />
|
||||
<img alt="They took my blood but it wasn't DNA, it was USA" src="{{ url_for('static', filename='photos/blinkies/usa.gif') }}" loading="lazy" width="150" height="20" />
|
||||
<img alt="Bob the Builder: can we build it?" src="{{ url_for('static', filename='photos/blinkies/bobthebuilder.gif') }}" loading="lazy" width="150" height="20" />
|
||||
</p>
|
||||
</div>
|
||||
#}
|
||||
<figure class="colophon-figure flex">
|
||||
<img
|
||||
src="{{ url_for('static', filename='photos/extradimensional.avif') }}"
|
||||
alt="Seraphim over a globe"
|
||||
width="180"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<figcaption></figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
32
src/templates/partials/feature_project.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% macro feature_project(id, title, tagline, body, stack, image=none, caption='') %}
|
||||
<article class="feature{{ ' has-figure' if image else '' }}" id="feature-{{ id }}">
|
||||
<div class="feature-head">
|
||||
<h3>{{ title }}</h3>
|
||||
<p class="feature-tagline">{{ tagline }}</p>
|
||||
</div>
|
||||
{% if image %}
|
||||
<figure class="feature-figure feature-clip">
|
||||
<img src="{{ url_for('static', filename=image) }}" alt="{{ caption or title }}" loading="lazy" width="1108" height="1017" />
|
||||
{% if caption %}<figcaption>{{ caption }}</figcaption>{% endif %}
|
||||
</figure>
|
||||
{% endif %}
|
||||
<div class="feature-body feature-clip" id="feature-{{ id }}-body">
|
||||
<div class="prose">
|
||||
{{ body | safe }}
|
||||
</div>
|
||||
{% if stack %}
|
||||
<p class="feature-stack"><span class="label">Built with</span> {{ stack | join(', ') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button
|
||||
class="feature-expand"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="feature-{{ id }}-body"
|
||||
onclick="toggleFeature(this)"
|
||||
>
|
||||
<span class="visually-hidden when-closed">Read more about {{ title }}</span>
|
||||
<span class="visually-hidden when-open">Collapse {{ title }}</span>
|
||||
</button>
|
||||
</article>
|
||||
{% endmacro %}
|
||||
@@ -1,32 +1,27 @@
|
||||
{% macro project(title, classes, status, bgi, content, links) %}
|
||||
<div class="project {{ classes }}" data-aos="fade-up">
|
||||
<div class="projImageWrap">
|
||||
{% set tags = classes.split() | reject('equalto', 'pinned') | list %}
|
||||
<li class="project" id="project-{{ title | slug }}">
|
||||
<div class="project-thumb">
|
||||
{% if bgi|length > 0 %}
|
||||
{% set path = url_for('static', filename='photos/projects/' + bgi) %}
|
||||
<img src="{{ path }}" alt="Ref image for {{ title }} project" />
|
||||
<img src="{{ url_for('static', filename='photos/projects/' + bgi) }}" alt="" loading="lazy" />
|
||||
{% else %}
|
||||
<div class="projImagePlaceholder"></div>
|
||||
<div class="project-thumb-empty" aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
<div class="proj-status-badge {{ status }}">
|
||||
<span class="status-indicator"></span>{{ status }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="projContent">
|
||||
<div class="project-body">
|
||||
<h3>{{ title }}</h3>
|
||||
<p class="projDesc">{{ content }}</p>
|
||||
<p class="project-meta">
|
||||
<span class="status status-{{ status }}">{{ status }}</span>
|
||||
{% if tags %}<span class="sep" aria-hidden="true">·</span> {{ tags | join(', ') }}{% endif %}
|
||||
</p>
|
||||
<p class="project-desc">{{ content }}</p>
|
||||
{% if links %}
|
||||
<div class="projLinks">
|
||||
{% for i in links %}
|
||||
{% set src = 'icons/' + i[0] + '.svg' %}
|
||||
{% if i[1].startswith('https://') or i[1].startswith('http://') %}
|
||||
<a href="{{ i[1] }}" rel="noopener noreferrer" class="proj-link">
|
||||
<img class="projectLink" src="{{ url_for('static', filename=src) }}" alt="{{ i[0] }}" />
|
||||
<span>{{ i[2] }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<p class="project-links">
|
||||
{% for i in links if i[1].startswith('https://') or i[1].startswith('http://') %}
|
||||
<a href="{{ i[1] }}" rel="noopener noreferrer">{{ i[2] }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% macro expandSkill(dict, name, classes="") %}
|
||||
<div class='skill {{ classes }}' data-length='{{ dict[name]|length }}'>
|
||||
<div onclick='activeSkill(this)' class='skillname'>{{ name }}</div>
|
||||
<div onclick='activeSkill(this)' onKeyDown="if(event.key==='Enter'||event.key===' ')activeSkill(this)" role='button' tabindex='0' aria-expanded='false' class='skillname'>{{ name }}</div>
|
||||
{% if dict[name]|length > 0 %}
|
||||
<div class='skill-children'>
|
||||
<div class='skill-children-inner'>
|
||||
@@ -14,7 +14,7 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro skills(skills) %}
|
||||
<div id="skillTree">
|
||||
<div id="skillTree" role='tree' aria-label='Skills'>
|
||||
{% for skill in skills %}
|
||||
{% set classes = "" %}
|
||||
{% if skill == skills|first %}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<div class='socials'>
|
||||
<a href='https://github.com/asimonson1125'><img alt='Github' src="{{ url_for('static', filename='icons/github.svg') }}" /></a>
|
||||
<a href='https://www.linkedin.com/in/simonsonandrew/'><img alt='LinkedIn' src="{{ url_for('static', filename='icons/linkedin.svg') }}" /></a>
|
||||
<a href='mailto:asimonson1125@gmail.com'><img alt='E-mail' src="{{ url_for('static', filename='icons/email.svg') }}" /></a>
|
||||
<div id='vertLine'></div>
|
||||
<div class='socials' role='contentinfo' aria-label='Social links'>
|
||||
<a href='https://github.com/asimonson1125' aria-label='GitHub profile' rel='noopener noreferrer'>
|
||||
<img alt='' src="{{ url_for('static', filename='icons/github.svg') }}" />
|
||||
</a>
|
||||
<a href='https://www.linkedin.com/in/simonsonandrew/' aria-label='LinkedIn profile' rel='noopener noreferrer'>
|
||||
<img alt='' src="{{ url_for('static', filename='icons/linkedin.svg') }}" />
|
||||
</a>
|
||||
<a href='mailto:asimonson1125@gmail.com' aria-label='Send an email'>
|
||||
<img alt='' src="{{ url_for('static', filename='icons/email.svg') }}" />
|
||||
</a>
|
||||
<div id='vertLine' aria-hidden='true'></div>
|
||||
</div>
|
||||
@@ -1,68 +1,33 @@
|
||||
{% block content %}
|
||||
<div class="foreground"></div>
|
||||
<div class="foregroundContent">
|
||||
<div class="flex equalitems vertOnMobile">
|
||||
<div>
|
||||
<div>
|
||||
<h2 class="concentratedHead">About Me<p><sup>Data Scientist, Amateur SysAdmin, Polymath</sup></p></h2>
|
||||
<p>
|
||||
I'm Andrew Simonson<!--, CEO of the anti-thermodynamics syndicate.-->,
|
||||
a <strong>Data Scientist at Ecolab</strong> and a graduate Data
|
||||
Science student at
|
||||
<strong>Rochester Institute of Technology</strong>, having
|
||||
recently completed the <b>Computer Science BS</b> program
|
||||
(international relations minor) with a focus on probability
|
||||
theory.
|
||||
<!-- <br />
|
||||
<br />
|
||||
I started in ~2017, reverse engineering probablistic logic
|
||||
models in games and developing interfaces to recreate my
|
||||
findings for friends. Now I develop tracable AI built on
|
||||
deductive reasoning, maintaning scientific methodology in an
|
||||
industry obsessed with implicit rules and exclusive empiricism.
|
||||
As the analysis grew more sophisticated, so too did the tech
|
||||
stack - to the point that I now manage most services, like this
|
||||
website, end to end, container image to insight visual.
|
||||
|
||||
With substantial development and systems operation expertise, I
|
||||
am capable of working with small teams that require many critical
|
||||
functions, from devops to analytical platforms, to artificial
|
||||
intelligence implementations, to all be handled by the same person.
|
||||
Looking for an analyst who can create and operate the full digital
|
||||
stack on your crack team of scientists? You've come to the right place.
|
||||
-->
|
||||
<br />
|
||||
<br />
|
||||
I get bored and throw random stuff on this website.<br/>
|
||||
This is what unprofessional development looks like.
|
||||
<div class="page">
|
||||
<header class="page-head">
|
||||
<h1 class="display">Work</h1>
|
||||
<p class="lede">
|
||||
Research, tooling, and experiments. Sometimes I forget to add things to this list, message me for missing details.
|
||||
</p>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
<h4 class='concentratedHead'>
|
||||
I also have a
|
||||
<a href="certs">certifications page</a>
|
||||
and a
|
||||
<a href="Resume_Simonson_Andrew.pdf" target="_blank">resume</a>
|
||||
for unexplained reasons.
|
||||
</h4>
|
||||
</div>
|
||||
<div id="skills">
|
||||
<h2 id="skillstag">Technologies</h2>
|
||||
{% from 'partials/skills.html' import skills %} {{
|
||||
skills(var['skillList']) }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<br />
|
||||
<h2 class="concentratedHead">Projects</h2>
|
||||
<div class="projectList">
|
||||
{% from 'partials/project.html' import project %} {% for i in
|
||||
var["projects"] %} {{ project(i, var["projects"][i]["classes"],
|
||||
var["projects"][i]["status"], var["projects"][i]["bgi"],
|
||||
var["projects"][i]["content"], var["projects"][i]["links"]) }} {% endfor
|
||||
%}
|
||||
<section class="section" aria-labelledby="highlights-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="highlights-heading">Highlights</h2>
|
||||
</div>
|
||||
{% from 'partials/feature_project.html' import feature_project %}
|
||||
{% for id, f in var.features.items() %}
|
||||
{{ feature_project(id, f.title, f.tagline, f.body, f.stack, f.image, f.caption) }}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="all-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="all-heading">All projects</h2>
|
||||
<p class="section-note">{{ var.projects | length }} entries</p>
|
||||
</div>
|
||||
<ol class="project-list">
|
||||
{% from 'partials/project.html' import project %}
|
||||
{% for title, item in var.projects.items() %}
|
||||
{{ project(title, item.classes, item.status, item.bgi, item.content, item.links) }}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,100 +1,80 @@
|
||||
{% block content %}
|
||||
<div class="foreground"></div>
|
||||
<div class="foregroundContent">
|
||||
<h2 class='concentratedHead'>Service Status Monitor</h2>
|
||||
<div class="page">
|
||||
<header class="page-head">
|
||||
<h1 class="display">Status</h1>
|
||||
<p class="lede">
|
||||
Uptime for the services I host myself, checked every minute from the
|
||||
server. This page refreshes itself on the same schedule.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="info-box">
|
||||
<div class='flex spaceBetween'>
|
||||
<section class="section status-summary" id="overallStatus" role="status" aria-live="polite">
|
||||
<div class="status-summary-main">
|
||||
<span class="summary-icon loading" aria-hidden="true">◐</span>
|
||||
<div>
|
||||
<span id="lastUpdate">Last checked: Loading...</span>
|
||||
<br />
|
||||
<span id="nextUpdate">Next check: --</span>
|
||||
</div>
|
||||
<button id="refreshBtn" onclick="refreshStatus()">Refresh Now</button>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
<h4>Status Legend</h4>
|
||||
<div class="legend-items">
|
||||
<div class="legend-item">
|
||||
<span class="state-dot online"></span>
|
||||
<span>Operational (response successful)</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="state-dot degraded"></span>
|
||||
<span>Degraded (timeout or errors)</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="state-dot offline"></span>
|
||||
<span>Offline (unreachable)</span>
|
||||
<h2 class="summary-title">Checking systems</h2>
|
||||
<p class="summary-subtitle" id="summary-subtitle">Loading service status</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status Bar -->
|
||||
<div class="info-card summary-card" id="overallStatus">
|
||||
<div class="summary-content">
|
||||
<div class="summary-indicator">
|
||||
<span class="summary-icon loading">◐</span>
|
||||
<dl class="status-facts">
|
||||
<div>
|
||||
<h3 class="summary-title">Checking Systems...</h3>
|
||||
<h5 id="summary-subtitle">Loading service status</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex spaceBetween">
|
||||
<div class="metric-box">
|
||||
<span class="metric-number" id="onlineCount">--</span>
|
||||
<span class="metric-label">Online</span>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="metric-number" id="totalCount">--</span>
|
||||
<span class="metric-label">Total</span>
|
||||
</div>
|
||||
<dt>Online</dt>
|
||||
<dd><span id="onlineCount" aria-label="Online services">--</span> of <span id="totalCount" aria-label="Total services">--</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last checked</dt>
|
||||
<dd id="lastUpdate">Loading</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Next check</dt>
|
||||
<dd id="nextUpdate">--</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button id="refreshBtn" class="button" type="button" onclick="refreshStatus()" aria-label="Refresh service status now">Refresh now</button>
|
||||
</section>
|
||||
|
||||
<div class="card-grid">
|
||||
<section class="section" aria-labelledby="services-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="services-heading">Services</h2>
|
||||
<p class="section-note">Uptime over 24 hours, 7 days, 30 days, and all time</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Service</th>
|
||||
<th scope="col">State</th>
|
||||
<th scope="col" class="num">Response</th>
|
||||
<th scope="col" class="num">Code</th>
|
||||
<th scope="col" class="num">Checks</th>
|
||||
<th scope="col">Uptime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for service in var.services %}
|
||||
<a href="{{ service.url }}">
|
||||
<div class="info-card" id="status-{{ service.id }}">
|
||||
<div class="card-header">
|
||||
<h3>{{ service.name }}</h3>
|
||||
<div class="state-indicator">
|
||||
<span class="state-dot loading"></span>
|
||||
<span class="state-text">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Response Time:</span>
|
||||
<span class="metric-value" id="time-{{ service.id }}">--</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Status Code:</span>
|
||||
<span class="metric-value" id="code-{{ service.id }}">--</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<span class="metric-label">Total Checks:</span>
|
||||
<span class="metric-value" id="checks-{{ service.id }}">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="detail-label">Uptime:</div>
|
||||
<div class="detail-values" id="uptime-{{ service.id }}">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<tr id="status-{{ service.id }}">
|
||||
<th scope="row"><a href="{{ service.url }}" rel="noopener noreferrer">{{ service.name }}</a></th>
|
||||
<td>
|
||||
<span class="state-indicator">
|
||||
<span class="state-dot loading" aria-hidden="true"></span>
|
||||
<span class="state-text">Loading</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="num" id="time-{{ service.id }}">--</td>
|
||||
<td class="num" id="code-{{ service.id }}">--</td>
|
||||
<td class="num" id="checks-{{ service.id }}">--</td>
|
||||
<td class="uptime" id="uptime-{{ service.id }}">Loading</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h4>About This Monitor</h4>
|
||||
<ul>
|
||||
<li><strong>Check Frequency:</strong> Services are checked automatically every minute from the server</li>
|
||||
<li><strong>Page Refresh:</strong> This page auto-refreshes every minute to show latest data</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="table-note">
|
||||
<span class="state-dot online" aria-hidden="true"></span> operational
|
||||
<span class="state-dot degraded" aria-hidden="true"></span> degraded or timed out
|
||||
<span class="state-dot offline" aria-hidden="true"></span> unreachable
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/status.js') }}"></script>
|
||||
|
||||