Compare commits

1 Commits
master ... hdr

Author SHA1 Message Date
03d31a53cd hdr support - works in chrome 2024-01-11 10:08:00 -08:00
17 changed files with 5189 additions and 2910 deletions

5
.gitignore vendored
View File

@@ -1,9 +1,6 @@
*.swp *.swp
*.swo *.swo
dist/ dist/
img/* img/
!img/convert.sh
node_modules node_modules
.DS_Store .DS_Store
venv/
.idea/

View File

@@ -1,12 +1,11 @@
#!/bin/bash #!/bin/bash
set -e set -e
shopt -s nullglob
function make_jpg { function make_jpg {
if [ ! -f $2/$1 ]; then if [ ! -f $2/$1 ]; then
echo "Converting $2/$1" echo "Converting $2/$1"
magick $1 -resize $2x$2 -quality 30 $2/$1 convert $1 -resize $2x$2 -quality 30 $2/$1
fi fi
} }
@@ -14,15 +13,7 @@ function make_webp {
NAME="$(basename $1 .jpg).webp" NAME="$(basename $1 .jpg).webp"
if [ ! -f $2/$NAME ]; then if [ ! -f $2/$NAME ]; then
echo "Converting $2/$NAME" echo "Converting $2/$NAME"
magick $1 -resize $2x$2 -quality $3 $2/$NAME convert $1 -resize $2x$2 -quality 30 $2/$NAME
fi
}
function make_avif {
NAME="$(basename $1 .jpg).avif"
if [ ! -f $2/$NAME ]; then
echo "Converting $2/$NAME"
magick $1 -resize $2x$2 -quality $3 $2/$NAME
fi fi
} }
@@ -33,29 +24,19 @@ for f in original/*.jpg; do
done done
for img in *.jpg; do for img in *.jpg; do
make_jpg $img 2400 & make_jpg $img 2400
make_jpg $img 1600 & make_jpg $img 1600
make_jpg $img 1200 & make_jpg $img 1200
make_jpg $img 800 & make_jpg $img 800
make_jpg $img 600 & make_jpg $img 600
make_jpg $img 400 & make_jpg $img 400
make_jpg $img 200 & make_jpg $img 200
make_webp $img 2400 50 & make_webp $img 2400
make_webp $img 1600 50 & make_webp $img 1600
make_webp $img 1200 50 & make_webp $img 1200
make_webp $img 800 40 & make_webp $img 800
make_webp $img 600 40 & make_webp $img 600
make_webp $img 400 40 & make_webp $img 400
make_webp $img 200 40 & make_webp $img 200
make_avif $img 2400 70 &
make_avif $img 1600 70 &
make_avif $img 1200 70 &
make_avif $img 800 60 &
make_avif $img 600 60 &
make_avif $img 400 50 &
make_avif $img 200 50 &
wait
done done

View File

@@ -3,7 +3,7 @@
<head> <head>
<title>Skiing - Aaron Gutierrez</title> <title>Skiing - Aaron Gutierrez</title>
<link rel="stylesheet" href="site.css"> <link rel="stylesheet" href="site.css">
<meta charset="utf-8"> <meta charshet="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
</head> </head>
<body> <body>

6546
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,18 +15,18 @@
"author": "Aaron Gutierrez", "author": "Aaron Gutierrez",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"react": "^19.1.0", "@types/react": "^18.0.26",
"react-dom": "^19.1.0" "@types/react-dom": "^18.0.10",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "19.1.0", "esbuild": "^0.16.10",
"@types/react-dom": "19.1.0", "source-map-loader": "^4.0.1",
"esbuild": "^0.25.4", "ts-loader": "^9.4.2",
"source-map-loader": "^5.0.0", "typescript": "^4.9.4",
"ts-loader": "^9.5.2", "webpack": "^5.66.0",
"typescript": "^5.8.3", "webpack-cli": "^5.0.1",
"webpack": "^5.99.9", "webpack-dev-server": "^4.7.3"
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.1"
} }
} }

3
pub.py
View File

@@ -4,6 +4,7 @@ import argparse
import hashlib import hashlib
import os import os
import subprocess import subprocess
import sys
from functools import cache from functools import cache
import boto3 import boto3
@@ -19,7 +20,6 @@ cloudfront = session.client('cloudfront')
TYPE_MAP = { TYPE_MAP = {
'avif': 'image/avif',
'css': 'text/css', 'css': 'text/css',
'gif': 'image/gif', 'gif': 'image/gif',
'html': 'text/html; charset=utf8', 'html': 'text/html; charset=utf8',
@@ -28,6 +28,7 @@ TYPE_MAP = {
'json': 'application/json', 'json': 'application/json',
'png': 'image/png', 'png': 'image/png',
'webp': 'image/webp', 'webp': 'image/webp',
'avif': 'image/avif',
} }

View File

@@ -118,56 +118,10 @@ a:hover {
z-index: 100; z-index: 100;
} }
.BigPicture-viewport {
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
align-items: center;
position: relative;
touch-action: none;
}
.BigPicture-track {
display: flex;
align-items: center;
height: 100%;
will-change: transform;
}
.BigPicture-track.is-dragging {
cursor: grabbing;
}
.BigPicture-frame {
align-items: center;
display: flex;
justify-content: center;
max-width: 100%;
padding: 20px;
width: 100%;
}
.BigPicture-frame.is-dragging {
cursor: grabbing;
}
.BigPicture-imageWrapper {
display: flex;
align-items: center;
justify-content: center;
will-change: transform;
}
.BigPicture-zoomTarget {
transition: transform 0.2s ease;
will-change: transform;
}
.BigPicture img { .BigPicture img {
transition-duration: 0.2s; transition-duration: 0.2s;
transition-timing-function: ease-in-out; transition-timing-function: ease-in-quart;
transition-property: height, width, opacity; transition-property: height, width;
} }
.BigPicture-footer { .BigPicture-footer {
@@ -175,7 +129,6 @@ a:hover {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
justify-content: space-between; justify-content: space-between;
margin-bottom: 8px;
max-width: 200px; max-width: 200px;
width: 100%; width: 100%;
} }

View File

@@ -1,11 +1,10 @@
import * as Model from "../model";
import { Picture } from "./picture";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
import { Picture } from "components/picture";
export interface Props { export interface Props {
image: Model.Image; image: Model.Image;
previousImage: Model.Image;
nextImage: Model.Image;
onClose: () => void; onClose: () => void;
showNext: () => void; showNext: () => void;
showPrevious: () => void; showPrevious: () => void;
@@ -17,457 +16,93 @@ interface TouchStart {
y: number; y: number;
} }
interface PinchState { export interface State {
distance: number; touchStart?: TouchStart | null;
startZoom: number;
} }
interface Point { export class BigPicture extends React.PureComponent<Props, State> {
x: number; static displayName = "BigPicture";
y: number;
}
export const BigPicture: React.FC<Props> = ({ componentDidMount() {
image, window.addEventListener("keyup", this._onEscape as any);
previousImage, window.addEventListener("touchstart", this._onTouchStart as any);
nextImage, window.addEventListener("touchend", this._onTouchEnd as any);
onClose,
showNext,
showPrevious,
width,
}) => {
const viewportRef = React.useRef<HTMLDivElement | null>(null);
const [touchStart, setTouchStart] = React.useState<TouchStart | null>(null);
const [dragOffset, setDragOffset] = React.useState(0);
const [isDragging, setIsDragging] = React.useState(false);
const [animating, setAnimating] = React.useState(false);
const [isSnapping, setIsSnapping] = React.useState(false);
const [zoom, setZoom] = React.useState(1);
const [pan, setPan] = React.useState<Point>({ x: 0, y: 0 });
const [panStart, setPanStart] = React.useState<Point | null>(null);
const [pinchState, setPinchState] = React.useState<PinchState | null>(null);
const [isPointerPanning, setIsPointerPanning] = React.useState(false);
const tapStartRef = React.useRef<Point | null>(null);
const lastTapRef = React.useRef<{ time: number; x: number; y: number } | null>(
null
);
const onEscape = React.useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
},
[onClose]
);
const goNext = React.useCallback(() => {
showNext();
}, [showNext]);
const goPrevious = React.useCallback(() => {
showPrevious();
}, [showPrevious]);
const getViewportCenter = React.useCallback((): Point | null => {
const node = viewportRef.current;
if (!node) {
return null;
}
const rect = node.getBoundingClientRect();
return { x: rect.width / 2, y: rect.height / 2 };
}, []);
const clampPan = React.useCallback(
(nextPan: Point, nextZoom?: number) => {
const node = viewportRef.current;
if (!node) {
return nextPan;
}
const z = nextZoom ?? zoom;
if (z <= 1) {
return { x: 0, y: 0 };
}
const viewportRect = node.getBoundingClientRect();
const viewportWidth = viewportRect.width;
const viewportHeight = viewportRect.height;
const imgScaleWidth = image.width / width;
const imgScaleHeight = image.height / (window.innerHeight - 80);
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
const displayWidth = image.width / imgScale;
const displayHeight = image.height / imgScale;
const maxPanX = Math.max(0, (displayWidth * z - viewportWidth) / 2);
const maxPanY = Math.max(0, (displayHeight * z - viewportHeight) / 2);
return {
x: Math.min(maxPanX, Math.max(-maxPanX, nextPan.x)),
y: Math.min(maxPanY, Math.max(-maxPanY, nextPan.y)),
};
},
[image.height, image.width, width, zoom]
);
const applyZoomAt = React.useCallback(
(nextZoom: number, focalPoint?: Point) => {
const clampedZoom = Math.min(4, Math.max(1, nextZoom));
const center = getViewportCenter();
if (!center) {
setZoom(clampedZoom);
setPan({ x: 0, y: 0 });
return;
}
const focus = focalPoint ?? center;
const ratio = clampedZoom / zoom;
const nextPan = {
x: pan.x * ratio + (focus.x - center.x) * (1 - ratio),
y: pan.y * ratio + (focus.y - center.y) * (1 - ratio),
};
setZoom(clampedZoom);
setPan(clampPan(nextPan, clampedZoom));
},
[clampPan, getViewportCenter, pan.x, pan.y, zoom]
);
const startSwipe = React.useCallback((touch: React.Touch) => {
setTouchStart({ x: touch.clientX, y: touch.clientY });
setIsDragging(true);
setDragOffset(0);
}, []);
const onTouchStart = React.useCallback(
(e: React.TouchEvent) => {
tapStartRef.current = null;
if (e.touches.length === 2) {
const [a, b] = [e.touches[0], e.touches[1]];
const dx = a.clientX - b.clientX;
const dy = a.clientY - b.clientY;
const distance = Math.hypot(dx, dy);
setPinchState({ distance, startZoom: zoom });
setIsDragging(false);
setTouchStart(null);
setPanStart(null);
return;
}
if (zoom > 1) {
const touch = e.touches[0];
setPanStart({ x: touch.clientX, y: touch.clientY });
setIsDragging(false);
setTouchStart(null);
tapStartRef.current = { x: touch.clientX, y: touch.clientY };
return;
}
const touch = e.touches[0];
startSwipe(touch);
tapStartRef.current = { x: touch.clientX, y: touch.clientY };
},
[startSwipe, zoom]
);
const onTouchEnd = React.useCallback(
(e: React.TouchEvent) => {
const touch = e.changedTouches[0];
const tapStart = tapStartRef.current;
const now = Date.now();
const isTap =
tapStart &&
Math.hypot(tapStart.x - touch.clientX, tapStart.y - touch.clientY) < 10;
const lastTap = lastTapRef.current;
if (isTap && lastTap && now - lastTap.time < 350) {
const distance = Math.hypot(lastTap.x - touch.clientX, lastTap.y - touch.clientY);
if (distance < 40) {
const rect = viewportRef.current?.getBoundingClientRect();
const focal = rect
? { x: touch.clientX - rect.left, y: touch.clientY - rect.top }
: undefined;
const fitScale = Math.max(
image.width / width,
image.height / (window.innerHeight - 80)
);
const targetZoom = zoom > 1.05 ? 1 : Math.min(4, fitScale);
applyZoomAt(targetZoom, focal);
setIsDragging(false);
setTouchStart(null);
setPanStart(null);
tapStartRef.current = null;
lastTapRef.current = null;
return;
}
}
if (isTap) {
lastTapRef.current = { time: now, x: touch.clientX, y: touch.clientY };
}
if (pinchState) {
setPinchState(null);
return;
}
if (zoom > 1) {
setPanStart(null);
return;
}
if (!touchStart) {
return;
}
const dx = touch.clientX - touchStart.x;
const threshold = Math.max(50, width * 0.08);
const movedEnough = Math.abs(dx) > threshold;
setIsDragging(false);
setTouchStart(null);
if (movedEnough) {
const target = dx < 0 ? -width : width;
setAnimating(true);
setDragOffset(target);
setTimeout(() => {
setIsSnapping(true);
if (dx < 0) {
goNext();
} else {
goPrevious();
}
requestAnimationFrame(() => {
setDragOffset(0);
setAnimating(false);
requestAnimationFrame(() => setIsSnapping(false));
});
}, 220);
} else {
setDragOffset(0);
}
},
[applyZoomAt, goNext, goPrevious, image.height, image.width, pinchState, touchStart, width, zoom]
);
const onTouchMove = React.useCallback(
(e: React.TouchEvent) => {
if (pinchState && e.touches.length === 2) {
e.preventDefault();
const [a, b] = [e.touches[0], e.touches[1]];
const dx = a.clientX - b.clientX;
const dy = a.clientY - b.clientY;
const distance = Math.hypot(dx, dy);
const rect = viewportRef.current?.getBoundingClientRect();
const midpoint = {
x: (a.clientX + b.clientX) / 2 - (rect?.left ?? 0),
y: (a.clientY + b.clientY) / 2 - (rect?.top ?? 0),
};
const ratio = distance / pinchState.distance;
applyZoomAt(pinchState.startZoom * ratio, midpoint);
return;
}
if (zoom > 1 && panStart) {
e.preventDefault();
const touch = e.touches[0];
setPan((prev) =>
clampPan(
{
x: prev.x + (touch.clientX - panStart.x),
y: prev.y + (touch.clientY - panStart.y),
},
zoom
)
);
setPanStart({ x: touch.clientX, y: touch.clientY });
return;
}
if (!touchStart) {
return;
}
const touch = e.touches[0];
const dx = touch.clientX - touchStart.x;
// Allow slight vertical movement but ignore large vertical swipes to avoid accidental nav
const dy = Math.abs(touch.clientY - touchStart.y);
if (dy > Math.abs(dx) * 1.5) {
return;
}
setDragOffset(dx);
},
[applyZoomAt, clampPan, panStart, pinchState, touchStart, zoom]
);
React.useEffect(() => {
window.addEventListener("keyup", onEscape);
document.body.classList.add("no-scroll"); document.body.classList.add("no-scroll");
}
return () => { componentWillUnmount() {
window.removeEventListener("keyup", onEscape); window.removeEventListener("keyup", this._onEscape as any);
document.body.classList.remove("no-scroll"); window.removeEventListener("touchstart", this._onTouchStart as any);
}; window.removeEventListener("touchend", this._onTouchEnd as any);
}, [onEscape]); document.body.classList.remove("no-scroll");
}
React.useEffect(() => { render() {
setZoom(1); const scaleWidth = this.props.image.width / this.props.width;
setPan({ x: 0, y: 0 }); const scaleHeight = this.props.image.height / (window.innerHeight - 80);
}, [image.src]); const scale = Math.max(scaleWidth, scaleHeight);
const onWheel = React.useCallback( return (
(e: WheelEvent) => { <div className="BigPicture">
if (!viewportRef.current) { <Picture
return; image={this.props.image}
} onClick={() => {}}
const rect = viewportRef.current.getBoundingClientRect(); height={this.props.image.height / scale}
const focal = { width={this.props.image.width / scale}
x: e.clientX - rect.left, />
y: e.clientY - rect.top, <div className="BigPicture-footer">
}; <a
const delta = -e.deltaY * (e.ctrlKey ? 0.0025 : 0.0015); className="BigPicture-footerLink"
const nextZoom = zoom * (1 + delta); href={`img/${this.props.image.src}`}
if (Math.abs(delta) > 0.0001) { target="_blank"
e.preventDefault(); >
} Download
applyZoomAt(nextZoom, focal); </a>
}, <span
[applyZoomAt, zoom] className="BigPicture-footerLink"
); role="button"
onClick={this.props.onClose}
React.useEffect(() => { onKeyPress={this._keyPress}
const node = viewportRef.current; tabIndex={0}
if (!node) { >
return; Close
} </span>
node.addEventListener("wheel", onWheel, { passive: false });
return () => {
node.removeEventListener("wheel", onWheel);
};
}, [onWheel]);
React.useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!isPointerPanning || zoom <= 1) {
return;
}
setPan((prev) =>
clampPan(
{
x: prev.x + e.movementX,
y: prev.y + e.movementY,
},
zoom
)
);
};
const onMouseUp = () => {
setIsPointerPanning(false);
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [clampPan, isPointerPanning, zoom]);
const onMouseDown = React.useCallback(
(e: React.MouseEvent) => {
if (zoom <= 1 || e.button !== 0) {
return;
}
e.preventDefault();
setIsPointerPanning(true);
},
[zoom]
);
const slideWidth = width;
const trackBase = -slideWidth + dragOffset;
const trackStyle: React.CSSProperties = {
width: `${slideWidth * 3}px`,
transform: `translate3d(${trackBase}px, 0, 0)`,
transition: isDragging || isSnapping ? "none" : "transform 0.25s ease",
};
const slideStyle: React.CSSProperties = {
width: `${slideWidth}px`,
flex: `0 0 ${slideWidth}px`,
opacity: animating ? 0.98 : 1,
transition: isDragging ? "none" : "opacity 0.2s ease",
};
return (
<div className="BigPicture">
<div
className="BigPicture-viewport"
ref={viewportRef}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onMouseDown={onMouseDown}
>
<div
className={`BigPicture-track${isDragging ? " is-dragging" : ""}`}
style={trackStyle}
>
{[previousImage, image, nextImage].map((img) => {
const imgScaleWidth = img.width / width;
const imgScaleHeight = img.height / (window.innerHeight - 80);
const imgScale = Math.max(imgScaleWidth, imgScaleHeight);
const isCurrent = img.src === image.src;
return (
<div
className="BigPicture-frame"
key={img.src}
style={slideStyle}
>
<div
className="BigPicture-imageWrapper"
style={
isCurrent
? { transform: `translate3d(${pan.x}px, ${pan.y}px, 0)` }
: undefined
}
>
<div
className="BigPicture-zoomTarget"
style={isCurrent ? { transform: `scale(${zoom})` } : undefined}
>
<Picture
image={img}
onClick={() => {}}
height={img.height / imgScale}
width={img.width / imgScale}
/>
</div>
</div>
</div>
);
})}
</div> </div>
</div> </div>
<div className="BigPicture-footer"> );
<a }
className="BigPicture-footerLink"
href={`img/${image.src}`} private _keyPress = (e: React.KeyboardEvent) => {
target="_blank" if (e.key === "Enter") {
rel="noreferrer" this.props.onClose();
download={image.src} }
> };
Download
</a> private _onEscape = (e: React.KeyboardEvent) => {
<span if (e.key === "Escape") {
className="BigPicture-footerLink" this.props.onClose();
role="button" }
onClick={onClose} };
onKeyPress={(e) => {
if (e.key === "Enter") { private _onTouchStart = (e: React.TouchEvent) => {
onClose(); const touch = e.touches[0];
} this.setState({ touchStart: { x: touch.screenX, y: touch.screenY } });
}} };
tabIndex={0}
> private _onTouchEnd = (e: React.TouchEvent) => {
Close const touch = e.changedTouches[0];
</span> const touchStart = this.state.touchStart as TouchStart;
</div>
</div> const dx = touch.screenX - touchStart.x;
);
}; if (Math.abs(dx) / window.innerWidth > 0.05) {
if (dx < 0) {
this.props.showNext();
} else {
this.props.showPrevious();
}
}
this.setState({ touchStart: null });
};
}

View File

@@ -1,6 +1,7 @@
import { Picture } from "./picture";
import * as Model from "../model";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
import { Picture } from "components/picture";
export interface Props { export interface Props {
images: Model.Image[]; images: Model.Image[];
@@ -10,7 +11,7 @@ export interface Props {
height: number; height: number;
} }
export const ROW_HEIGHT = 350; export const ROW_HEIGHT = 250;
export const MOBILE_ROW_HEIGHT = 150; export const MOBILE_ROW_HEIGHT = 150;
interface Row { interface Row {
@@ -18,127 +19,72 @@ interface Row {
width: number; width: number;
} }
interface BadList { export class Grid extends React.PureComponent<Props, {}> {
splits: number[]; static displayName: string = "Grid";
badness: number;
}
const badness = (row: Model.Image[], width: number, height: number): number => { private gridHeight = 0;
const rowWidth = row.reduce((w, img) => w + img.width / img.height, 0);
const rowHeight = width / rowWidth;
return (rowHeight - height) * (rowHeight - height); render() {
}; this.gridHeight = 0;
export const Grid: React.FC<Props> = ({ let row: Model.Image[] = [];
images, const rows: Row[] = [];
onImageSelected, let rowWidth = 0;
pageBottom,
width,
height,
}) => {
const rowsMemo = React.useRef<Map<number, Map<number, BadList>>>(new Map());
const targetRowHeight = width > 900 ? ROW_HEIGHT : MOBILE_ROW_HEIGHT;
React.useEffect(() => { this.props.images.forEach((image) => {
// The memoized rows depend on the image list; clear when the set changes const newWidth = rowWidth + image.width / image.height;
rowsMemo.current.clear(); const height = this.props.width / newWidth;
}, [images]);
const rowsFor = (idx: number): BadList => { if (height < this._rowHeight()) {
const memo = rowsMemo.current.get(width) ?? new Map(); rows.push({
const maybeMemo = memo.get(idx); images: row,
width: rowWidth,
});
if (maybeMemo) { row = [];
return maybeMemo; rowWidth = image.width / image.height;
} } else {
rowWidth = newWidth;
if (idx === images.length) {
return {
splits: [],
badness: 0,
};
}
if (idx === images.length - 1) {
const img = images[idx];
const h = (img.height * width) / img.width;
return {
splits: [],
badness: (targetRowHeight - h) * (targetRowHeight - h),
};
}
let leastBad = 1e50;
let bestSplits: number[] = [];
for (let i = idx + 1; i <= images.length; i++) {
const rowBadness = badness(images.slice(idx, i), width, targetRowHeight);
const rest = rowsFor(i);
const totalBadness = rest.badness + rowBadness;
if (totalBadness < leastBad) {
leastBad = totalBadness;
bestSplits = [i, ...rest.splits];
} }
} row.push(image);
});
rows.push({
images: row,
width: rowWidth,
});
const badList = { const images = rows.map((row) => {
splits: bestSplits, const height = Math.min(this.props.height, this.props.width / row.width);
badness: leastBad,
};
memo.set(idx, badList); const pics = row.images.map((image) => {
return (
<Picture
image={image}
onClick={() => this.props.onImageSelected(image)}
key={image.src}
height={height}
width={(image.width / image.height) * height}
defer={this.gridHeight > this.props.pageBottom}
/>
);
});
if (!rowsMemo.current.has(width)) { this.gridHeight += height;
rowsMemo.current.set(width, memo);
}
return badList;
};
let gridHeight = 0;
const badList = rowsFor(0);
let lastBreak = 0;
const rows: Row[] = badList.splits.map((split) => {
const slice = images.slice(lastBreak, split);
lastBreak = split;
return {
images: slice,
width: slice.reduce((acc, img) => acc + img.width / img.height, 0),
};
});
const pictures = rows.map((row) => {
const rowHeight = Math.min(height, width / row.width);
const pics = row.images.map((image) => {
const scaledWidth = (image.width / image.height) * rowHeight;
const defer = gridHeight > pageBottom;
return ( return (
<Picture <div
image={image} className="Grid-row"
onClick={() => onImageSelected(image)} style={{ height: height + "px" }}
key={image.src} key={row.images.map((image) => image.src).join(",")}
height={rowHeight} >
width={scaledWidth} {pics}
defer={defer} </div>
/>
); );
}); });
gridHeight += rowHeight; return <div className="Grid">{images}</div>;
}
return ( private _rowHeight = (): number =>
<div this.props.width > 900 ? ROW_HEIGHT : MOBILE_ROW_HEIGHT;
className="Grid-row" }
style={{ height: rowHeight + "px" }}
key={row.images.map((image) => image.src).join(",")}
>
{pics}
</div>
);
});
return <div className="Grid">{pictures}</div>;
};

View File

@@ -1,48 +1,61 @@
import { Grid } from "./grid";
import * as Model from "../model";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
import { Grid } from "components/grid";
export interface Props { export interface Props {
imageSet: Model.ImageSet; imageSet: Model.ImageSet;
onImageSelected: (img: Model.Image) => void; onImageSelected: (img: Model.Image) => void;
onShowHome: () => void; onShowHome: () => void;
setGridHeight: (height: number) => void;
pageBottom: number; pageBottom: number;
width: number; width: number;
height: number; height: number;
} }
export const ImageSet: React.FC<Props> = ({ export class ImageSet extends React.PureComponent<Props, {}> {
imageSet, static displayName = "ImageSet";
onImageSelected,
onShowHome, private divRef: React.RefObject<HTMLDivElement> = React.createRef();
pageBottom,
width, render() {
height, return (
}) => { <div className="ImageSet" ref={this.divRef}>
return ( <h2>
<div className="ImageSet"> <span className="ImageSet-location">
<h2> {this.props.imageSet.location}
<span className="ImageSet-location">{imageSet.location}</span> </span>
<span className="ImageSet-description">{imageSet.description}</span> <span className="ImageSet-description">
</h2> {this.props.imageSet.description}
<Grid </span>
images={imageSet.images} </h2>
onImageSelected={onImageSelected} <Grid
pageBottom={pageBottom} images={this.props.imageSet.images}
width={width} onImageSelected={this.props.onImageSelected}
height={height} pageBottom={this.props.pageBottom}
/> width={this.props.width}
<div className="ImageSet-navigation"> height={this.props.height}
<a />
href="#" <div className="ImageSet-navigation">
onClick={(e) => { <a href="#" onClick={this.props.onShowHome}>
e.preventDefault(); Back
onShowHome(); </a>
}} </div>
>
Back
</a>
</div> </div>
</div> );
); }
};
componentDidMount() {
this._setGridHeight();
}
componentDidUpdate() {
this._setGridHeight();
}
private _setGridHeight = () => {
if (this.divRef.current) {
this.props.setGridHeight(this.divRef.current.clientHeight);
}
};
}

View File

@@ -1,5 +1,6 @@
import * as Model from "../model";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
export interface Props { export interface Props {
image: Model.Image; image: Model.Image;
@@ -9,6 +10,10 @@ export interface Props {
defer?: boolean; defer?: boolean;
} }
export interface State {
isMounted: boolean;
}
interface SrcSetInfo { interface SrcSetInfo {
jpeg: string; jpeg: string;
webp: string; webp: string;
@@ -16,20 +21,49 @@ interface SrcSetInfo {
bestSrc: string; bestSrc: string;
} }
export const Picture: React.FC<Props> = ({ export class Picture extends React.PureComponent<Props, State> {
image, static displayName = "Picture";
onClick,
height,
width,
defer,
}) => {
const [isMounted, setIsMounted] = React.useState(false);
React.useEffect(() => { state: State = {
setIsMounted(true); isMounted: false,
}, []); };
const srcSet = React.useMemo(() => { componentDidMount() {
this.setState({ isMounted: true });
}
render() {
if (this.props.defer || !this.state.isMounted) {
return (
<div
className="Picture-defer"
style={{ width: this.props.width + "px" }}
/>
);
}
const srcSet = this._srcset();
return (
<picture>
{ srcSet.avif !== ""
? <source srcSet={srcSet.avif} type="image/avif" media="(dynamic-range: high)" />
: null
}
<source srcSet={srcSet.webp} type="image/webp" />
<source srcSet={srcSet.jpeg} type="image/jpeg" />
<img
id={this.props.image.src}
onClick={this.props.onClick}
src={srcSet.bestSrc}
height={this.props.height + "px"}
width={Math.floor(this.props.width) + "px"}
/>
</picture>
);
}
private _srcset = (): SrcSetInfo => {
const jpegSrcSet: string[] = []; const jpegSrcSet: string[] = [];
const webpSrcSet: string[] = []; const webpSrcSet: string[] = [];
const avifSrcSet: string[] = []; const avifSrcSet: string[] = [];
@@ -37,18 +71,22 @@ export const Picture: React.FC<Props> = ({
let bestScale = Infinity; let bestScale = Infinity;
Model.SIZES.forEach((size) => { Model.SIZES.forEach((size) => {
const derivedWidth = const width =
image.width > image.height ? size : (image.width / image.height) * size; this.props.image.width > this.props.image.height
? size
: (this.props.image.width / this.props.image.height) * size;
const scale = derivedWidth / width; const scale = width / this.props.width;
if (scale >= 1 || size === 2400) { if (scale >= 1 || size === 2400) {
const jpeg = `img/${size}/${image.src}`; const jpeg = `img/${size}/${this.props.image.src}`;
const webp = jpeg.replace("jpg", "webp"); const webp = jpeg.replace("jpg", "webp");
const avif = jpeg.replace("jpg", "avif"); const avif = jpeg.replace("jpg", "avif");
jpegSrcSet.push(`${jpeg} ${scale}x`); jpegSrcSet.push(`${jpeg} ${scale}x`);
webpSrcSet.push(`${webp} ${scale}x`); webpSrcSet.push(`${webp} ${scale}x`);
avifSrcSet.push(`${avif} ${scale}x`); if (this.props.image.hdr) {
avifSrcSet.push(`${avif} ${scale}x`);
}
if (scale < bestScale) { if (scale < bestScale) {
bestSize = size; bestSize = size;
bestScale = scale; bestScale = scale;
@@ -60,27 +98,7 @@ export const Picture: React.FC<Props> = ({
jpeg: jpegSrcSet.join(","), jpeg: jpegSrcSet.join(","),
webp: webpSrcSet.join(","), webp: webpSrcSet.join(","),
avif: avifSrcSet.join(","), avif: avifSrcSet.join(","),
bestSrc: `img/${bestSize}/${image.src}`, bestSrc: `img/${bestSize}/${this.props.image.src}`,
}; };
}, [image, width]); };
}
if (defer || !isMounted) {
return <div className="Picture-defer" style={{ width: width + "px" }} />;
}
return (
<picture>
<source srcSet={srcSet.avif} type="image/avif" />
<source srcSet={srcSet.webp} type="image/webp" />
<source srcSet={srcSet.jpeg} type="image/jpeg" />
<img
id={image.src}
onClick={onClick}
src={srcSet.bestSrc}
height={height + "px"}
width={Math.floor(width) + "px"}
alt=""
/>
</picture>
);
};

View File

@@ -1,271 +1,224 @@
import { BigPicture } from "./big_picture";
import { ImageSet } from "./image_set";
import { SetCover } from "./set_cover";
import * as Model from "../model";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
import { BigPicture } from "components/big_picture";
import { ImageSet } from "components/image_set";
import { SetCover } from "components/set_cover";
export interface Props {} export interface Props {}
const viewWidth = (): number => { export interface State {
const widths = [ data?: Model.Data | null;
window.innerWidth, selectedImage?: Model.Image | null;
window.outerWidth, selectedSet?: Model.ImageSet | null;
document.documentElement?.clientWidth, gridHeights: number[];
document.body?.clientWidth, pageBottom: number;
].filter((w): w is number => typeof w === "number" && w > 0 && isFinite(w)); width: number;
height: number;
}
return widths.length > 0 ? Math.max(...widths) : 0; export class Root extends React.PureComponent<Props, State> {
}; static displayName = "Root";
const viewHeight = (): number => { // innerWidth gets messed up when rotating phones from landscape -> portrait,
const heights = [ // and chrome seems to not report innerWidth correctly when scrollbars are present
window.innerHeight, private _viewWidth = (): number => {
window.outerHeight, return Math.min(
document.documentElement?.clientHeight, window.innerWidth,
document.body?.clientHeight, window.outerWidth || Infinity,
].filter((h): h is number => typeof h === "number" && h > 0 && isFinite(h)); document.body.clientWidth
);
};
return heights.length > 0 ? Math.max(...heights) : 0; private _viewHeight = (): number => {
}; return Math.min(
window.outerHeight || Infinity,
document.body.clientHeight || Infinity
);
};
const formatHash = (set: Model.ImageSet) => state: State = {
set.location.replace(/[^a-zA-Z0-9-_]/g, "-") + gridHeights: [],
"-" + pageBottom: this._viewHeight() + window.pageYOffset,
set.description.replace(/[^a-zA-Z0-9-_]/g, "-"); width: this._viewWidth(),
height: this._viewHeight(),
};
export const Root: React.FC<Props> = () => { componentDidMount() {
const [data, setData] = React.useState<Model.Data | null>(null);
const [selectedImage, setSelectedImage] = React.useState<Model.Image | null>(
null
);
const [selectedSet, setSelectedSet] = React.useState<Model.ImageSet | null>(
null
);
const [dimensions, setDimensions] = React.useState(() => {
const height = viewHeight();
return {
pageBottom: height + window.pageYOffset,
width: viewWidth(),
height,
};
});
const updateView = React.useCallback(() => {
const height = viewHeight();
setDimensions({
pageBottom: height + window.pageYOffset,
width: viewWidth(),
height,
});
}, []);
const loadHash = React.useCallback(() => {
if (window.location.hash.length === 0) {
setSelectedImage(null);
setSelectedSet(null);
return;
}
if (!data) {
return;
}
const hash = window.location.hash.slice(1);
let nextImage: Model.Image | null = null;
let nextSet: Model.ImageSet | null = null;
data.sets.forEach((set) => {
if (formatHash(set) === hash) {
nextSet = set;
}
const image = set.images.find((img) => img.src === hash);
if (image) {
nextImage = image;
nextSet = set;
}
});
setSelectedImage(nextImage);
setSelectedSet(nextSet);
}, [data]);
React.useEffect(() => {
let isMounted = true;
window window
.fetch(Model.dataUrl) .fetch(Model.dataUrl)
.then((response) => response.json()) .then((data) => data.json())
.then((json) => { .then((json) => this.setState({ data: json }))
if (isMounted) { .then(this._loadHash)
setData(json); .then(this._onViewChange)
}
})
.catch((e) => console.error("Error fetching data", e)); .catch((e) => console.error("Error fetching data", e));
return () => { window.onresize = this._onViewChange;
isMounted = false; window.onscroll = this._onViewChange;
};
}, []);
React.useEffect(() => { try {
if (data) { screen.orientation.onchange = this._onViewChange;
loadHash(); } catch (e) {}
}
}, [data, loadHash]);
React.useEffect(() => { try {
const handlePopState = () => loadHash(); window.onorientationchange = this._onViewChange;
} catch (e) {}
window.addEventListener("resize", updateView); window.onpopstate = this._loadHash;
window.addEventListener("scroll", updateView, { passive: true }); }
window.addEventListener("orientationchange", updateView);
window.addEventListener("popstate", handlePopState);
const orientation = (screen as any).orientation; private _renderSet(set: Model.ImageSet) {
if (orientation?.addEventListener) { return (
orientation.addEventListener("change", updateView); <ImageSet
} key={set.location + set.description}
imageSet={set}
updateView(); pageBottom={this.state.pageBottom}
setGridHeight={this._setGridHeight(0)}
return () => { onImageSelected={this._onImageSelected}
window.removeEventListener("resize", updateView); onShowHome={this._onHomeSelected}
window.removeEventListener("scroll", updateView); width={this.state.width}
window.removeEventListener("orientationchange", updateView); height={this.state.height}
window.removeEventListener("popstate", handlePopState); />
if (orientation?.removeEventListener) { );
orientation.removeEventListener("change", updateView); }
}
};
}, [loadHash, updateView]);
React.useEffect(() => {
if (selectedSet) {
document.title =
selectedSet.location +
" " +
selectedSet.description +
" Skiing - Aaron Gutierrez";
} else {
document.title = "Skiing - Aaron Gutierrez";
}
}, [selectedSet]);
const onImageSelected = React.useCallback(
(img: Model.Image) => {
if (selectedImage) {
window.history.replaceState(null, "", `#${img.src}`);
} else {
window.history.pushState(null, "", `#${img.src}`);
}
setSelectedImage(img);
},
[selectedImage]
);
const onSetSelected = React.useCallback((set: Model.ImageSet) => {
setSelectedSet(set);
window.history.pushState(null, "", `#${formatHash(set)}`);
}, []);
const onHomeSelected = React.useCallback(() => {
setSelectedSet(null);
setSelectedImage(null);
window.history.pushState(null, "", "#");
}, []);
const showGrid = React.useCallback(() => {
setSelectedImage(null);
window.history.go(-1);
if (selectedSet) {
onSetSelected(selectedSet);
}
}, [onSetSelected, selectedSet]);
const showNextBigPicture = React.useCallback(() => {
if (!selectedSet || !selectedImage) {
return;
}
const images: Model.Image[] = selectedSet.images;
const current = images.indexOf(selectedImage);
const next = current + 1 >= images.length ? 0 : current + 1;
onImageSelected(images[next]);
}, [onImageSelected, selectedImage, selectedSet]);
const showPreviousBigPicture = React.useCallback(() => {
if (!selectedSet || !selectedImage) {
return;
}
const images: Model.Image[] = selectedSet.images;
const current = images.indexOf(selectedImage);
const previous = current - 1 < 0 ? images.length - 1 : current - 1;
onImageSelected(images[previous]);
}, [onImageSelected, selectedImage, selectedSet]);
const neighbors = React.useMemo(() => {
if (!selectedSet || !selectedImage) {
return null;
}
const images = selectedSet.images;
const current = images.indexOf(selectedImage);
const previousIdx = current - 1 < 0 ? images.length - 1 : current - 1;
const nextIdx = current + 1 >= images.length ? 0 : current + 1;
return {
previous: images[previousIdx],
next: images[nextIdx],
};
}, [selectedImage, selectedSet]);
const renderSets = () => {
if (!data) {
return null;
}
if (selectedSet) {
return (
<ImageSet
key={selectedSet.location + selectedSet.description}
imageSet={selectedSet}
pageBottom={dimensions.pageBottom}
onImageSelected={onImageSelected}
onShowHome={onHomeSelected}
width={dimensions.width}
height={dimensions.height}
/>
);
}
private _renderSetCovers(sets: Model.ImageSet[]) {
return ( return (
<div className="Root-setCovers"> <div className="Root-setCovers">
{data.sets.map((set) => ( {sets.map((set) => (
<SetCover <SetCover
key={set.location + set.description} key={set.location + set.description}
imageSet={set} imageSet={set}
onClick={() => { onClick={() => {
onSetSelected(set); this._onSetSelected(set);
scrollTo(0, 0); scrollTo(0, 0);
}} }}
width={Math.min(dimensions.width, 400)} width={Math.min(this.state.width, 400)}
/> />
))} ))}
</div> </div>
); );
}
render() {
const imageSets = this.state.data
? this.state.selectedSet
? this._renderSet(this.state.selectedSet)
: this._renderSetCovers(this.state.data.sets)
: null;
return (
<div className="Root">
{this._bigPicture()}
<h1 onClick={this._onHomeSelected}>Aaron's Ski Pictures</h1>
{imageSets}
</div>
);
}
private _bigPicture = () =>
this.state.selectedImage ? (
<BigPicture
image={this.state.selectedImage}
onClose={this._showGrid}
showNext={this._showNextBigPicture}
showPrevious={this._showPreviousBigPicture}
width={this.state.width}
/>
) : null;
private _loadHash = () => {
if (window.location.hash.length > 0 && this.state.data) {
const hash = window.location.hash.slice(1);
let selectedImage: Model.Image | null = null;
let selectedSet: Model.ImageSet | null = null;
this.state.data.sets.forEach((set) => {
if (this._setToHash(set) === hash) {
selectedSet = set;
}
const image = set.images.find((image) => image.src === hash);
if (image) {
selectedImage = image;
selectedSet = set;
}
});
this.setState({ selectedImage, selectedSet });
} else {
this.setState({ selectedImage: null, selectedSet: null });
}
}; };
return ( private _onViewChange = () => {
<div className="Root"> this.setState({
{selectedImage ? ( pageBottom: this._viewHeight() + window.pageYOffset,
<BigPicture width: this._viewWidth(),
image={selectedImage} height: this._viewHeight(),
previousImage={neighbors?.previous ?? selectedImage} });
nextImage={neighbors?.next ?? selectedImage} };
onClose={showGrid}
showNext={showNextBigPicture} private _onImageSelected = (img: Model.Image) => {
showPrevious={showPreviousBigPicture} this.setState({ selectedImage: img });
width={dimensions.width} window.history.pushState(null, "", `#${img.src}`);
/> };
) : null}
<h1 onClick={onHomeSelected}>Aaron's Ski Pictures</h1> private _onSetSelected = (set: Model.ImageSet) => {
{renderSets()} this.setState({ selectedSet: set });
</div> document.title =
); set.location + " " + set.description + " Skiing - Aaron Gutierrez";
}; window.history.pushState(null, "", `#${this._setToHash(set)}`);
};
private _onHomeSelected = () => {
this.setState({
selectedSet: null,
selectedImage: null,
});
window.history.pushState(null, "", "#");
document.title = "Skiing - Aaron Gutierrez";
};
private _setToHash = (set: Model.ImageSet) =>
set.location.replace(/[^a-zA-Z0-9-_]/g, "-") +
"-" +
set.description.replace(/[^a-zA-Z0-9-_]/g, "-");
private _showGrid = () => {
this.setState({ selectedImage: null });
this._onSetSelected(this.state.selectedSet as Model.ImageSet);
};
private _showNextBigPicture = () => {
const images: Model.Image[] = this.state.selectedSet
?.images as Model.Image[];
const current = images.indexOf(this.state.selectedImage as Model.Image);
const next = current + 1 >= images.length ? 0 : current + 1;
this._onImageSelected(images[next]);
};
private _showPreviousBigPicture = () => {
const images: Model.Image[] = this.state.selectedSet
?.images as Model.Image[];
const current = images.indexOf(this.state.selectedImage as Model.Image);
const previous = current - 1 < 0 ? images.length - 1 : current - 1;
this._onImageSelected(images[previous]);
};
private _setGridHeight = (grid: number) => (height: number) => {
if (this.state.gridHeights[grid] === height) {
return;
}
this.setState((state) => {
const newGridHeights = [...state.gridHeights];
newGridHeights[grid] = height;
return { gridHeights: newGridHeights };
});
};
private _getPreviousGridHeights = (grid: number): number =>
this.state.gridHeights.slice(0, grid).reduce((a, b) => a + b, 0);
}

View File

@@ -1,6 +1,7 @@
import { Picture } from "./picture";
import * as Model from "../model";
import * as React from "react"; import * as React from "react";
import * as Model from "model";
import { Picture } from "components/picture";
export interface Props { export interface Props {
imageSet: Model.ImageSet; imageSet: Model.ImageSet;
@@ -8,30 +9,40 @@ export interface Props {
width: number; width: number;
} }
export const SetCover: React.FC<Props> = ({ imageSet, onClick, width }) => { export interface State {}
const coverImage = imageSet.images[0];
const isTall = coverImage.height > coverImage.width;
const height = isTall export class SetCover extends React.PureComponent<Props, State> {
? width static displayName = "SetCover";
: (coverImage.height / coverImage.width) * width;
const normalizedWidth = isTall render() {
? (coverImage.width / coverImage.height) * width const image = this.props.imageSet.images[0];
: width; const isTall = image.height > image.width;
return ( const height = isTall
<div className="SetCover" onClick={onClick}> ? this.props.width
<Picture : (image.height / image.width) * this.props.width;
image={coverImage}
onClick={() => {}} const width = isTall
height={height} ? (image.width / image.height) * this.props.width
width={normalizedWidth} : this.props.width;
/>
<h2> return (
<span className="SetCover-location">{imageSet.location}</span> <div className="SetCover" onClick={this.props.onClick}>
<span className="SetCover-description">{imageSet.description}</span> <Picture
</h2> image={image}
</div> onClick={() => {}}
); height={height}
}; width={width}
/>
<h2>
<span className="SetCover-location">
{this.props.imageSet.location}
</span>
<span className="SetCover-description">
{this.props.imageSet.description}
</span>
</h2>
</div>
);
}
}

View File

@@ -1,7 +1,8 @@
import { Root } from "components/root"; import { Root } from "./components/root";
import { createRoot } from "react-dom/client"; import * as React from "react";
import * as ReactDOM from "react-dom";
const body = document.getElementById("mount") as HTMLElement; const body = document.getElementById("mount");
const root = createRoot(body);
root.render(<Root />); ReactDOM.render(<Root />, body);

View File

@@ -16,4 +16,5 @@ export interface Image {
src: string; src: string;
height: number; height: number;
width: number; width: number;
hdr?: boolean;
} }

View File

@@ -1,18 +1,13 @@
{ {
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/", "outDir": "./dist/",
"baseUrl": "./src", "baseUrl": ".",
"sourceMap": true, "sourceMap": true,
"noImplicitAny": true, "noImplicitAny": true,
"strictNullChecks": true, "strictNullChecks": true,
"strict": true, "target": "es6",
"target": "ES2020", "jsx": "react",
"jsx": "react-jsx", "module": "es6"
"module": "ESNext",
"moduleResolution": "Node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}, },
"include": [ "include": [
"./src/**/*" "./src/**/*"

View File

@@ -7,15 +7,16 @@ module.exports = (env) => {
entry: "./src/index.tsx", entry: "./src/index.tsx",
output: { output: {
filename: "bundle.js", filename: "bundle.js",
path: path.join(__dirname, "dist"), path: path.join(__dirname, "dist")
clean: true,
}, },
mode: mode, mode: mode,
target: "web",
// Enable sourcemaps for debugging webpack's output. // Enable sourcemaps for debugging webpack's output.
devtool: "source-map", devtool: "source-map",
performance: {
hints: false
},
devServer: { devServer: {
static: { static: {
@@ -26,8 +27,7 @@ module.exports = (env) => {
resolve: { resolve: {
// Add '.ts' and '.tsx' as resolvable extensions. // Add '.ts' and '.tsx' as resolvable extensions.
extensions: [".ts", ".tsx", ".js", ".json"], extensions: [".ts", ".tsx", ".js", ".json"]
modules: [path.resolve(__dirname, "src"), "node_modules"],
}, },
module: { module: {
@@ -37,9 +37,5 @@ module.exports = (env) => {
{ enforce: "pre", test: /\.js$/, loader: "source-map-loader" } { enforce: "pre", test: /\.js$/, loader: "source-map-loader" }
] ]
}, },
experiments: {
topLevelAwait: true
}
} }
}; };