mirror of
https://github.com/ParkerTenBroeck/ParkerTenBroeck.github.io.git
synced 2026-06-06 21:14:06 -04:00
updated QTip
This commit is contained in:
parent
9356e79e1a
commit
458a6ced12
11 changed files with 428 additions and 4 deletions
|
|
@ -7,4 +7,20 @@ date = 2025-05-12
|
||||||
tags = ["6-sided", "crafty"]
|
tags = ["6-sided", "crafty"]
|
||||||
+++
|
+++
|
||||||
|
|
||||||
I'm so sorry for whoever opened this page expecting to see such a object. Unfortunately I have not yet uploaded the image. Please yell at me if needed
|
As promised here is the cube.
|
||||||
|
|
||||||
|
{{image(src="images/qtip/cube.jpg", caption="A near cube made entirely out of QTips that is 10x10x11 (-2) in size. No glue added")}}
|
||||||
|
|
||||||
|
You might wonder how to construct such an object yourself.
|
||||||
|
|
||||||
|
What ended up working for me was laying out a bottom row of QTips with a width just slightly longer than the length of the QTips' shaft.
|
||||||
|
|
||||||
|
{{image(src="images/qtip/step.jpg", caption="A bottom layer of QTips showing the width compared to the shaft length of a QTip")}}
|
||||||
|
|
||||||
|
Continue alternating the direction in which you align the QTips until you have stacked enough layers to be slightly higher than the length of the QTips' shaft.
|
||||||
|
|
||||||
|
It is very important that the grid is aligned well at this point, as you will begin inserting QTips downward through each opening in the formed grid.
|
||||||
|
|
||||||
|
I started with the 4 outer corners, then filled the outer edges, then filled the center.
|
||||||
|
|
||||||
|
If all goes well, you will have a lovely cube which is fairly sturdy.
|
||||||
|
|
@ -14,5 +14,5 @@ let
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
pkgs.mkShell {
|
pkgs.mkShell {
|
||||||
buildInputs = [ unstable.zola ];
|
buildInputs = [ unstable.zola pkgs.exiftool ];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
remove_metadata.sh
Executable file
1
remove_metadata.sh
Executable file
|
|
@ -0,0 +1 @@
|
||||||
|
exiftool -all= -tagsfromfile @ -Orientation -overwrite_original -r ./static/images ./static/videos
|
||||||
56
sass/img.scss
Normal file
56
sass/img.scss
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
/* Prevent page scroll when lightbox is open */
|
||||||
|
body.lb-open {
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none; /* disables background gestures */
|
||||||
|
}
|
||||||
|
|
||||||
|
figure.image img.zoomable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// figure.image img.zoomable:hover {
|
||||||
|
// transform: scale(1.02);
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
/* Overlay */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto; /* blocks clicks/scroll to the page behind */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage fills viewport; it’s the only interaction surface */
|
||||||
|
.lightbox__stage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden; /* hide panned edges */
|
||||||
|
cursor: zoom-out; /* click outside to close */
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__img {
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 95vh;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
cursor: grab;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
will-change: transform;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox__img.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
@use "footer.scss";
|
@use "footer.scss";
|
||||||
@use "blog.scss";
|
@use "blog.scss";
|
||||||
@use "fonts.scss";
|
@use "fonts.scss";
|
||||||
|
@use "img.scss";
|
||||||
|
|
||||||
.z-code{
|
.z-code{
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
@ -90,4 +91,31 @@ code {
|
||||||
time {
|
time {
|
||||||
padding-bottom: 1em;
|
padding-bottom: 1em;
|
||||||
color: var(--fg-2);
|
color: var(--fg-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
figure {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure.image {
|
||||||
|
margin: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure.image img {
|
||||||
|
max-width: 80%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure.image figcaption {
|
||||||
|
max-width: 80%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
BIN
static/images/qtip/cube.jpg
Normal file
BIN
static/images/qtip/cube.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 491 KiB |
BIN
static/images/qtip/step.jpg
Normal file
BIN
static/images/qtip/step.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 KiB |
302
static/js/script.js
Normal file
302
static/js/script.js
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
(() => {
|
||||||
|
let lightboxHistoryActive = false;
|
||||||
|
|
||||||
|
const lightbox = document.getElementById("lightbox");
|
||||||
|
const stage = lightbox?.querySelector(".lightbox__stage");
|
||||||
|
const img = document.getElementById("lightbox-img");
|
||||||
|
if (!lightbox || !stage || !img) return;
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
|
||||||
|
// Transform state (relative to centered position)
|
||||||
|
let scale = 1;
|
||||||
|
let tx = 0;
|
||||||
|
let ty = 0;
|
||||||
|
|
||||||
|
// Base rendered size of the image at scale=1 (in px)
|
||||||
|
let baseW = 0;
|
||||||
|
let baseH = 0;
|
||||||
|
|
||||||
|
// Dragging
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
|
||||||
|
// Touch pinch
|
||||||
|
let touchMode = null;
|
||||||
|
let pinchStartDist = 0;
|
||||||
|
let pinchStartScale = 1;
|
||||||
|
let pinchMid = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
const MIN_SCALE = 1;
|
||||||
|
const MAX_SCALE = 6;
|
||||||
|
|
||||||
|
const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
|
||||||
|
|
||||||
|
const rect = (el) => el.getBoundingClientRect();
|
||||||
|
|
||||||
|
function measureBaseSize() {
|
||||||
|
// IMPORTANT: measure when tx/ty=0 and scale=1 so we get the centered base size.
|
||||||
|
const prev = img.style.transform;
|
||||||
|
img.style.transform = "translate(0px, 0px) scale(1)";
|
||||||
|
const r = rect(img);
|
||||||
|
baseW = r.width;
|
||||||
|
baseH = r.height;
|
||||||
|
img.style.transform = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPan() {
|
||||||
|
const s = rect(stage);
|
||||||
|
|
||||||
|
const scaledW = baseW * scale;
|
||||||
|
const scaledH = baseH * scale;
|
||||||
|
|
||||||
|
// When the image is smaller than the stage, keep it centered (no panning).
|
||||||
|
const maxX = Math.max(0, (scaledW - s.width) / 2);
|
||||||
|
const maxY = Math.max(0, (scaledH - s.height) / 2);
|
||||||
|
|
||||||
|
tx = clamp(tx, -maxX, maxX);
|
||||||
|
ty = clamp(ty, -maxY, maxY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransform() {
|
||||||
|
clampPan();
|
||||||
|
img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
scale = 1;
|
||||||
|
tx = 0;
|
||||||
|
ty = 0;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLightbox(src, alt) {
|
||||||
|
img.onload = null;
|
||||||
|
img.src = src;
|
||||||
|
img.alt = alt || "";
|
||||||
|
|
||||||
|
lightbox.classList.add("open");
|
||||||
|
lightbox.setAttribute("aria-hidden", "false");
|
||||||
|
document.body.classList.add("lb-open");
|
||||||
|
open = true;
|
||||||
|
|
||||||
|
if (!lightboxHistoryActive) {
|
||||||
|
history.pushState({ lightbox: true }, "");
|
||||||
|
lightboxHistoryActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block background scroll/gestures while open (especially iOS)
|
||||||
|
document.addEventListener("touchmove", preventTouchScroll, { passive: false });
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Wait for layout to settle, then measure and reset.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
measureBaseSize();
|
||||||
|
resetView();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// If cached and instantly available
|
||||||
|
if (img.complete) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
measureBaseSize();
|
||||||
|
resetView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox(fromPopState = false) {
|
||||||
|
lightbox.classList.remove("open");
|
||||||
|
lightbox.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.classList.remove("lb-open");
|
||||||
|
open = false;
|
||||||
|
|
||||||
|
img.classList.remove("dragging");
|
||||||
|
touchMode = null;
|
||||||
|
|
||||||
|
document.removeEventListener("touchmove", preventTouchScroll, { passive: false });
|
||||||
|
|
||||||
|
if (lightboxHistoryActive && !fromPopState) {
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
lightboxHistoryActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preventTouchScroll(e) {
|
||||||
|
if (open) e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomAt(clientX, clientY, newScale) {
|
||||||
|
const s = rect(stage);
|
||||||
|
|
||||||
|
const cx = clientX - s.left - s.width / 2;
|
||||||
|
const cy = clientY - s.top - s.height / 2;
|
||||||
|
|
||||||
|
const prevScale = scale;
|
||||||
|
scale = clamp(newScale, MIN_SCALE, MAX_SCALE);
|
||||||
|
|
||||||
|
// Keep point under cursor stable (relative to center-based coordinates)
|
||||||
|
tx = cx - ((cx - tx) / prevScale) * scale;
|
||||||
|
ty = cy - ((cy - ty) / prevScale) * scale;
|
||||||
|
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open on thumbnail click; close on backdrop click
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const thumb = e.target.closest("img.zoomable");
|
||||||
|
if (thumb) {
|
||||||
|
openLightbox(thumb.dataset.full || thumb.src, thumb.alt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (open && (e.target === lightbox || e.target === stage)) closeLightbox();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent clicking the image from closing
|
||||||
|
img.addEventListener("click", (e) => {
|
||||||
|
if (open) e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (open && e.key === "Escape") closeLightbox();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wheel / trackpad zoom
|
||||||
|
stage.addEventListener("wheel", (e) => {
|
||||||
|
if (!open) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||||
|
zoomAt(e.clientX, e.clientY, scale * factor);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// --- CLICK vs DRAG (Pointer Events) ---
|
||||||
|
let pointerId = null;
|
||||||
|
let downX = 0, downY = 0;
|
||||||
|
let startTx = 0, startTy = 0;
|
||||||
|
|
||||||
|
let moved = false;
|
||||||
|
const DRAG_THRESHOLD = 6; // px
|
||||||
|
const ZOOM_IN = 2.5;
|
||||||
|
|
||||||
|
function setZoomedClass() {
|
||||||
|
img.classList.toggle("is-zoomed", scale > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
img.addEventListener("pointerdown", (e) => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
pointerId = e.pointerId;
|
||||||
|
img.setPointerCapture(pointerId);
|
||||||
|
|
||||||
|
downX = e.clientX;
|
||||||
|
downY = e.clientY;
|
||||||
|
startTx = tx;
|
||||||
|
startTy = ty;
|
||||||
|
moved = false;
|
||||||
|
|
||||||
|
// Don’t let the browser start image dragging/scrolling
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
img.addEventListener("pointermove", (e) => {
|
||||||
|
if (!open || pointerId !== e.pointerId) return;
|
||||||
|
|
||||||
|
const dx = e.clientX - downX;
|
||||||
|
const dy = e.clientY - downY;
|
||||||
|
|
||||||
|
// Decide if this gesture is a pan
|
||||||
|
if (!moved && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
|
||||||
|
moved = true;
|
||||||
|
img.classList.add("dragging");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If panning, update translate (pan) from the start position
|
||||||
|
if (moved && scale > 1) {
|
||||||
|
tx = startTx + dx;
|
||||||
|
ty = startTy + dy;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
img.addEventListener("pointerup", (e) => {
|
||||||
|
if (!open || pointerId !== e.pointerId) return;
|
||||||
|
|
||||||
|
img.classList.remove("dragging");
|
||||||
|
img.releasePointerCapture(pointerId);
|
||||||
|
pointerId = null;
|
||||||
|
|
||||||
|
// If we didn't move enough, treat as a click: toggle zoom
|
||||||
|
if (!moved) {
|
||||||
|
// Zoom around where they clicked
|
||||||
|
const target = (scale === 1) ? ZOOM_IN : 1;
|
||||||
|
zoomAt(e.clientX, e.clientY, target);
|
||||||
|
setZoomedClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
moved = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
img.addEventListener("pointercancel", () => {
|
||||||
|
img.classList.remove("dragging");
|
||||||
|
pointerId = null;
|
||||||
|
moved = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch: pan + pinch
|
||||||
|
stage.addEventListener("touchstart", (e) => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
touchMode = "pan";
|
||||||
|
lastX = e.touches[0].clientX;
|
||||||
|
lastY = e.touches[0].clientY;
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
touchMode = "pinch";
|
||||||
|
const [a, b] = e.touches;
|
||||||
|
pinchStartDist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
||||||
|
pinchStartScale = scale;
|
||||||
|
pinchMid = { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
stage.addEventListener("touchmove", (e) => {
|
||||||
|
if (!open) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (touchMode === "pan" && e.touches.length === 1) {
|
||||||
|
const x = e.touches[0].clientX;
|
||||||
|
const y = e.touches[0].clientY;
|
||||||
|
tx += x - lastX;
|
||||||
|
ty += y - lastY;
|
||||||
|
lastX = x;
|
||||||
|
lastY = y;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touchMode === "pinch" && e.touches.length === 2) {
|
||||||
|
const [a, b] = e.touches;
|
||||||
|
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
||||||
|
const ratio = dist / pinchStartDist;
|
||||||
|
zoomAt(pinchMid.x, pinchMid.y, pinchStartScale * ratio);
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
stage.addEventListener("touchend", (e) => {
|
||||||
|
if (!open) return;
|
||||||
|
if (e.touches.length === 0) touchMode = null;
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
touchMode = "pan";
|
||||||
|
lastX = e.touches[0].clientX;
|
||||||
|
lastY = e.touches[0].clientY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("popstate", (e) => {
|
||||||
|
// If the lightbox is open, Back should close it — not leave the page
|
||||||
|
if (open) {
|
||||||
|
closeLightbox(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -117,6 +117,7 @@
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
||||||
<body data-theme="dark">
|
<body data-theme="dark">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
|
|
@ -137,6 +138,12 @@
|
||||||
{%- endfilter -%}{%- endfilter %}
|
{%- endfilter -%}{%- endfilter %}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<div id="lightbox" class="lightbox" aria-hidden="true" role="dialog" aria-label="Image viewer">
|
||||||
|
<div class="lightbox__stage">
|
||||||
|
<img id="lightbox-img" class="lightbox__img" alt="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<script src="{{ get_url(path='/js/script.js') }}"></script>
|
||||||
</html>
|
</html>
|
||||||
8
templates/macros/image.html
Normal file
8
templates/macros/image.html
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{% macro figure(src, caption="", class="") %}
|
||||||
|
<figure class="image {{ class }}">
|
||||||
|
<img src="{{ src }}" loading="lazy" decoding="async">
|
||||||
|
{% if caption != "" %}
|
||||||
|
<figcaption>{{ caption }}</figcaption>
|
||||||
|
{% endif %}
|
||||||
|
</figure>
|
||||||
|
{% endmacro %}
|
||||||
6
templates/shortcodes/image.html
Normal file
6
templates/shortcodes/image.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<figure class="image {% if class %} {{ class }} {% endif %}">
|
||||||
|
<img src="{{ get_url(path=src) }}" loading="lazy" decoding="async" class="zoomable" data-full="{{ get_url(path=src) }}">
|
||||||
|
{% if caption != "" %}
|
||||||
|
<figcaption>{{ caption }}</figcaption>
|
||||||
|
{% endif %}
|
||||||
|
</figure>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue