updated QTip

This commit is contained in:
Parker TenBroeck 2026-01-18 19:11:54 -05:00
parent 9356e79e1a
commit 458a6ced12
11 changed files with 428 additions and 4 deletions

View file

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

View file

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

@ -0,0 +1 @@
exiftool -all= -tagsfromfile @ -Orientation -overwrite_original -r ./static/images ./static/videos

56
sass/img.scss Normal file
View 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; its 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;
}

View file

@ -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;
@ -91,3 +92,30 @@ 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

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
View 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;
// Dont 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);
}
});
})();

View file

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

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

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