Files
spec-card/index.html
T

2903 lines
97 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Camera Spec Card Generator</title>
<meta name="description" content="Turn any photo into a shareable camera spec card. Automatically reads EXIF metadata — camera, lens, aperture, shutter speed, ISO — and generates a downloadable 1080×1080 card perfect for Instagram.">
<meta name="keywords" content="camera spec card generator, EXIF data overlay, photo specs card Instagram, shot on card maker, astrophotography spec card">
<meta property="og:title" content="Camera Spec Card Generator">
<meta property="og:description" content="Drop a photo, get a beautiful spec card with your camera settings. Free tool by @FateOfAstrophilia.">
<meta property="og:url" content="https://justinoros.github.io/spec-card/">
<meta name="twitter:card" content="summary_large_image">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0c10;
--surface: rgba(12,15,22,0.72);
--border: rgba(255,255,255,0.08);
--text: #e8eaf0;
--muted: rgba(232,234,240,0.45);
--accent: #c8a96e;
--accent-dim: rgba(200,169,110,0.15);
--drop-active: rgba(200,169,110,0.08);
}
body {
background-color: var(--bg);
background-image:
linear-gradient(to bottom, rgba(8,10,16,0.62) 0%, rgba(8,10,16,0.78) 60%, rgba(8,10,16,0.92) 100%),
url('bg.jpg');
background-size: cover;
background-position: center top;
background-attachment: fixed;
color: var(--text);
font-family: 'Space Grotesk', sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 28px 24px 120px;
}
header {
text-align: center;
margin-bottom: 28px;
}
.wordmark {
font-family: 'Space Mono', monospace;
font-size: 11px;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 18px;
opacity: 0.8;
}
h1 {
font-size: clamp(28px, 5vw, 46px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
color: var(--text);
}
h1 span {
color: var(--accent);
}
.subtitle {
margin-top: 14px;
font-size: 15px;
color: var(--muted);
font-weight: 300;
max-width: 360px;
line-height: 1.6;
}
.drop-zone {
width: 100%;
max-width: 520px;
border: 1.5px dashed rgba(200,169,110,0.3);
border-radius: 16px;
padding: 32px 32px;
text-align: center;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
background: var(--surface);
position: relative;
}
.drop-zone:hover, .drop-zone.drag-over {
background: var(--drop-active);
border-color: var(--accent);
}
.drop-icon {
width: 52px;
height: 52px;
margin: 0 auto 20px;
opacity: 0.5;
}
.drop-zone h2 {
font-size: 17px;
font-weight: 500;
margin-bottom: 8px;
}
.drop-zone p {
font-size: 13px;
color: var(--muted);
margin-bottom: 24px;
}
.browse-btn {
display: inline-block;
padding: 10px 24px;
background: var(--accent-dim);
border: 1px solid rgba(200,169,110,0.4);
border-radius: 8px;
color: var(--accent);
font-family: 'Space Grotesk', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
cursor: pointer;
transition: background 0.2s;
}
.browse-btn:hover {
background: rgba(200,169,110,0.22);
}
#file-input { display: none; }
.section-divider {
width: 100%;
max-width: 520px;
display: flex;
align-items: center;
gap: 16px;
margin: 20px 0;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
font-family: 'Space Mono', monospace;
}
.section-divider::before, .section-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
#output-section { display: none; width: 100%; max-width: 560px; flex-direction: column; align-items: center; }
#canvas-wrapper {
width: 100%;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 32px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06);
background: #000;
position: relative;
}
#canvas-outer {
position: relative;
width: 100%;
}
#preview-canvas { display: block; width: 100%; height: auto; }
#trash-zone {
display: none;
position: absolute;
bottom: -56px;
left: 50%;
transform: translateX(-50%);
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(220, 60, 60, 0.15);
border: 1.5px dashed rgba(220, 80, 80, 0.4);
align-items: center;
justify-content: center;
transition: background 0.15s, border-color 0.15s, transform 0.15s;
cursor: default;
z-index: 200;
}
#trash-zone.active {
display: flex;
}
#trash-zone.over {
background: rgba(220, 60, 60, 0.45);
border-color: rgba(220, 80, 80, 0.9);
transform: translateX(-50%) scale(1.15);
}
#trash-zone svg {
opacity: 0.7;
}
#trash-zone.over svg {
opacity: 1;
}
.drag-item {
position: absolute;
cursor: grab;
user-select: none;
display: flex;
align-items: flex-start;
gap: 0;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.1s;
}
.drag-item:hover {
background: rgba(255,255,255,0.06);
}
.drag-item.dragging {
cursor: grabbing;
background: rgba(200,169,110,0.15);
outline: 1px dashed rgba(200,169,110,0.5);
opacity: 1 !important;
z-index: 100;
}
.drag-item .snap-indicator {
display: none;
}
.drag-grid-hint {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
#drag-layer.dragging-active .drag-grid-hint {
opacity: 1;
}
.drag-handle-icon {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
font-size: 10px;
color: rgba(200,169,110,0.6);
pointer-events: none;
font-family: monospace;
}
.drag-item:hover .drag-handle-icon {
opacity: 1;
}
@media (hover: none) {
.drag-item { cursor: default; }
.drag-handle-icon { display: none; }
}
.actions {
display: flex;
gap: 12px;
margin-top: 24px;
width: 100%;
flex-wrap: wrap;
}
.btn-primary {
flex: 1;
min-width: 160px;
padding: 14px 28px;
background: var(--accent);
color: #0a0c10;
border: none;
border-radius: 10px;
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.03em;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover { opacity: 0.85; }
.btn-secondary {
flex: 1;
min-width: 160px;
padding: 14px 28px;
background: transparent;
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s;
}
.btn-secondary:hover { border-color: rgba(255,255,255,0.25); }
.ig-link {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 20px;
padding: 8px 16px;
background: rgba(200,169,110,0.08);
border: 1px solid rgba(200,169,110,0.22);
border-radius: 999px;
color: var(--accent);
font-size: 13px;
font-weight: 500;
text-decoration: none;
letter-spacing: 0.01em;
transition: background 0.2s, border-color 0.2s;
}
.ig-link:hover {
background: rgba(200,169,110,0.16);
border-color: rgba(200,169,110,0.45);
}
.ig-link svg { flex-shrink: 0; opacity: 0.85; }
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 48px 0;
color: var(--muted);
font-size: 14px;
}
.spinner {
width: 32px;
height: 32px;
border: 2px solid rgba(200,169,110,0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-state {
padding: 20px 24px;
background: rgba(220, 80, 80, 0.08);
border: 1px solid rgba(220, 80, 80, 0.25);
border-radius: 10px;
color: #e07070;
font-size: 13px;
line-height: 1.6;
width: 100%;
}
.edit-panel {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
margin-top: 20px;
}
.edit-panel h3 {
font-size: 11px;
font-family: 'Space Mono', monospace;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 18px;
}
.edit-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
}
.edit-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.edit-field label {
font-size: 10px;
font-family: 'Space Mono', monospace;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
}
.edit-field input {
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: 7px;
padding: 9px 12px;
color: var(--text);
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
font-weight: 500;
transition: border-color 0.2s;
outline: none;
width: 100%;
}
.edit-field input:focus {
border-color: rgba(200,169,110,0.5);
}
.edit-field input::placeholder {
color: rgba(232,234,240,0.2);
}
.edit-field input.has-value {
border-color: rgba(200,169,110,0.3);
color: var(--accent);
}
.btn-regenerate {
width: 100%;
margin-top: 18px;
padding: 12px;
background: rgba(200,169,110,0.1);
border: 1px solid rgba(200,169,110,0.3);
border-radius: 9px;
color: var(--accent);
font-family: 'Space Grotesk', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
transition: background 0.2s;
}
.second-cam-section {
width: 100%;
margin-top: 24px;
border-top: 1px solid var(--border);
padding-top: 20px;
}
.second-cam-toggle {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 4px 0;
user-select: none;
}
.second-cam-toggle h4 {
font-size: 11px;
font-family: 'Space Mono', monospace;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent);
opacity: 0.8;
}
.second-cam-toggle .chevron {
color: var(--accent);
opacity: 0.6;
transition: transform 0.2s;
font-size: 12px;
}
.second-cam-toggle.open .chevron {
transform: rotate(180deg);
}
.second-cam-fields {
display: none;
margin-top: 16px;
}
.second-cam-fields.open {
display: block;
}
.col-label-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
width: 100%;
margin-top: 18px;
border: 1.5px dashed rgba(200,169,110,0.25);
border-radius: 9px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
color: var(--muted);
font-size: 13px;
}
.bg-drop-zone:hover, .bg-drop-zone.drag-over {
background: rgba(200,169,110,0.07);
border-color: rgba(200,169,110,0.5);
color: var(--accent);
}
.bg-drop-zone span {
display: block;
font-size: 10px;
font-family: 'Space Mono', monospace;
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 4px;
color: var(--accent);
opacity: 0.7;
}
.btn-regenerate:hover { background: rgba(200,169,110,0.18); }
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-top: 18px;
gap: 12px;
}
.toggle-item {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 9px;
padding: 10px 14px;
cursor: pointer;
}
.toggle-item input[type=checkbox] { display: none; }
.toggle-switch {
width: 36px;
height: 20px;
background: rgba(255,255,255,0.1);
border-radius: 10px;
position: relative;
flex-shrink: 0;
transition: background 0.2s;
}
.toggle-switch::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
top: 3px;
left: 3px;
transition: transform 0.2s;
}
.toggle-item input:checked + .toggle-switch {
background: var(--accent);
}
.toggle-item input:checked + .toggle-switch::after {
transform: translateX(16px);
}
.toggle-label {
font-size: 12px;
font-family: 'Space Mono', monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.fits-notice {
font-size: 12px;
color: var(--muted);
line-height: 1.6;
margin-bottom: 16px;
padding: 10px 14px;
background: rgba(200,169,110,0.06);
border-radius: 7px;
border-left: 2px solid rgba(200,169,110,0.4);
}
.canvas-tap-hint {
text-align: center;
font-size: 11px;
font-family: 'Space Mono', monospace;
letter-spacing: 0.15em;
color: rgba(232,234,240,0.28);
margin-top: 10px;
text-transform: uppercase;
}
.field-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 24px;
backdrop-filter: blur(4px);
}
.field-overlay-box {
background: #12151c;
border: 1px solid rgba(200,169,110,0.3);
border-radius: 14px;
padding: 28px 24px;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 16px;
}
.field-overlay-box h4 {
font-size: 11px;
font-family: 'Space Mono', monospace;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.field-overlay-box input {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(200,169,110,0.3);
border-radius: 8px;
padding: 11px 14px;
color: var(--text);
font-family: 'Space Grotesk', sans-serif;
font-size: 16px;
font-weight: 500;
outline: none;
width: 100%;
}
.field-overlay-box input:focus {
border-color: var(--accent);
}
.field-overlay-btns {
display: flex;
gap: 10px;
}
.field-overlay-btns button {
flex: 1;
padding: 11px;
border-radius: 8px;
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
}
.field-overlay-btns .confirm {
background: var(--accent);
color: #0a0c10;
}
.field-overlay-btns .cancel {
background: rgba(255,255,255,0.06);
color: var(--muted);
}
.slider-row {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
margin-top: 18px;
}
.slider-row label {
display: flex;
justify-content: space-between;
font-size: 10px;
font-family: 'Space Mono', monospace;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
}
.slider-row label span {
color: var(--accent);
font-weight: 600;
}
.slider-row input[type=range] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 3px;
background: var(--border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.slider-row input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid #0a0c10;
}
.slider-row input[type=range]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid #0a0c10;
}
@media (max-width: 480px) {
body { padding: 32px 16px 60px; }
.drop-zone { padding: 40px 20px; }
}
</style>
</head>
<body>
<header>
<div class="wordmark">Camera Spec Card Generator</div>
<div style="font-size:12px; font-weight:300; color:rgba(232,234,240,0.4); letter-spacing:0.08em; margin-bottom:18px;">by Justin Oros</div>
<h1>Turn any photo into<br>a <span>camera spec card</span></h1>
<p class="subtitle">Drop a photo for your spec-card background. Optionally, drop a Siril FIT file. We'll read its EXIF metadata and generate a shareable spec overlay, perfect for Instagram.</p>
</header>
<div class="drop-zone" id="drop-zone">
<svg class="drop-icon" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="10" width="44" height="34" rx="4" stroke="#c8a96e" stroke-width="1.5"/>
<circle cx="26" cy="27" r="9" stroke="#c8a96e" stroke-width="1.5"/>
<circle cx="26" cy="27" r="4" fill="#c8a96e" opacity="0.4"/>
<rect x="16" y="10" width="8" height="5" rx="1.5" stroke="#c8a96e" stroke-width="1.5"/>
</svg>
<h2>Drop your photo here</h2>
<p>JPEG, PNG, TIFF, CR2 RAW, FITS — EXIF metadata will be extracted automatically</p>
<label class="browse-btn" for="file-input">Browse files</label>
<input type="file" id="file-input" accept="image/*,.cr2,.cr3,.tif,.tiff,.fit,.fits">
</div>
<a class="ig-link" id="ig-link-top" href="https://www.instagram.com/FateOfAstrophilia" target="_blank" rel="noopener">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="5"/>
<circle cx="12" cy="12" r="4"/>
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/>
</svg>
@FateOfAstrophilia
</a>
<div id="output-section">
<div class="section-divider">Your spec card</div>
<div id="status-container"></div>
<div id="canvas-outer">
<div id="canvas-wrapper" style="display:none;">
<canvas id="preview-canvas"></canvas>
<div id="drag-layer">
<canvas class="drag-grid-hint" id="grid-canvas"></canvas>
</div>
</div>
<div id="trash-zone">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#ff6b6b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</div>
</div>
<div class="canvas-tap-hint" id="tap-hint" style="display:none;">Tap any field on the card to edit it</div>
<div class="actions" id="card-actions" style="display:none;">
<button class="btn-primary" id="download-btn">⬇ Download card</button>
<button class="btn-secondary" id="upload-btn">⬆ Upload image</button>
<input type="file" id="upload-any-input" accept="*/*" style="display:none;">
<button class="btn-secondary" id="reset-btn" style="display:none;">Try another photo</button>
</div>
<div id="edit-panel" class="edit-panel" style="display:none;">
<h3>Edit / Fill in specs</h3>
<div id="fits-notice" class="fits-notice" style="display:none;"></div>
<div class="edit-grid">
<div class="edit-field" style="grid-column: 1 / -1;">
<label>Header text (shown at top of card)</label>
<input type="text" id="ef-header" placeholder="e.g. @FateOfAstrophilia or SPEC CARD" value="SPEC CARD">
</div>
<div class="edit-field">
<label>Camera / Scope</label>
<input type="text" id="ef-camera" placeholder="e.g. Seestar S30 Pro">
</div>
<div class="edit-field">
<label>Telescope</label>
<input type="text" id="ef-telescope" placeholder="e.g. ZWO TC40">
</div>
<div class="edit-field">
<label>Lens</label>
<input type="text" id="ef-lens" placeholder="e.g. Sigma 16-28mm">
</div>
<div class="edit-field">
<label>Focal Length</label>
<input type="text" id="ef-focal" placeholder="e.g. 24mm">
</div>
<div class="edit-field">
<label>Aperture</label>
<input type="text" id="ef-aperture" placeholder="e.g. f/2.8">
</div>
<div class="edit-field">
<label>Filter</label>
<input type="text" id="ef-filter" placeholder="e.g. LP Filter or Ha">
</div>
<div class="edit-field">
<label>Shutter / Exposure</label>
<input type="text" id="ef-shutter" placeholder="e.g. 25s">
</div>
<div class="edit-field">
<label>Frames Stacked</label>
<input type="text" id="ef-frames" placeholder="e.g. 240 frames">
</div>
<div class="edit-field">
<label>Total Integration</label>
<input type="text" id="ef-totalexp" placeholder="e.g. 2h 40min">
</div>
<div class="edit-field">
<label>ISO / Gain</label>
<input type="text" id="ef-iso" placeholder="e.g. ISO 3200">
</div>
<div class="edit-field">
<label>Object / Subject</label>
<input type="text" id="ef-object" placeholder="e.g. Lagoon Nebula (M8)">
</div>
<div class="edit-field">
<label>Location</label>
<input type="text" id="ef-location" placeholder="e.g. Puebla, MX">
</div>
<div class="edit-field">
<label>Bortle Scale</label>
<input type="text" id="ef-bortle" placeholder="e.g. Bortle 4">
</div>
<div class="edit-field">
<label>Date</label>
<input type="text" id="ef-date" placeholder="e.g. Jun 1, 2025">
</div>
<div class="edit-field" style="grid-column: 1 / -1;">
<label>Footer text (IG handle, website, anything)</label>
<input type="text" id="ef-footer" placeholder="e.g. @FateOfAstrophilia or your website" value="@FateOfAstrophilia">
</div>
<div class="edit-field">
<label>Icon color</label>
<input type="text" id="ef-iconcolor" placeholder="#c8a96e" maxlength="7">
</div>
<div class="edit-field">
<label>Text color</label>
<input type="text" id="ef-textcolor" placeholder="#e8eaf0" maxlength="7">
</div>
</div>
<div class="second-cam-section">
<div class="second-cam-toggle" id="second-cam-toggle">
<h4>+ Second Camera / Foreground</h4>
<span class="chevron">▼</span>
</div>
<div class="second-cam-fields" id="second-cam-fields">
<div class="col-label-row">
<div class="edit-field">
<label>Column 1 label</label>
<input type="text" id="ef-col1label" placeholder="e.g. Sky" value="Sky">
</div>
<div class="edit-field">
<label>Column 2 label</label>
<input type="text" id="ef-col2label" placeholder="e.g. Foreground" value="Foreground">
</div>
</div>
<div class="edit-grid">
<div class="edit-field">
<label>Camera 2 / Scope</label>
<input type="text" id="ef2-camera" placeholder="e.g. Sony A7 III">
</div>
<div class="edit-field">
<label>Telescope 2</label>
<input type="text" id="ef2-telescope" placeholder="e.g. ZWO TC40">
</div>
<div class="edit-field">
<label>Lens 2</label>
<input type="text" id="ef2-lens" placeholder="e.g. Sigma 16-28mm">
</div>
<div class="edit-field">
<label>Focal Length 2</label>
<input type="text" id="ef2-focal" placeholder="e.g. 28mm">
</div>
<div class="edit-field">
<label>Aperture 2</label>
<input type="text" id="ef2-aperture" placeholder="e.g. f/3.5">
</div>
<div class="edit-field">
<label>Filter 2</label>
<input type="text" id="ef2-filter" placeholder="e.g. Ha">
</div>
<div class="edit-field">
<label>Shutter 2</label>
<input type="text" id="ef2-shutter" placeholder="e.g. 237s">
</div>
<div class="edit-field">
<label>Frames 2</label>
<input type="text" id="ef2-frames" placeholder="e.g. 12 frames">
</div>
<div class="edit-field">
<label>Total Integration 2</label>
<input type="text" id="ef2-totalexp" placeholder="e.g. 40min">
</div>
<div class="edit-field">
<label>ISO / Gain 2</label>
<input type="text" id="ef2-iso" placeholder="e.g. ISO 1000">
</div>
<div class="edit-field">
<label>Object 2</label>
<input type="text" id="ef2-object" placeholder="e.g. Foreground">
</div>
<div class="edit-field">
<label>Date 2</label>
<input type="text" id="ef2-date" placeholder="e.g. Jun 1, 2025">
</div>
</div>
</div>
</div>
<div class="slider-row">
<label>Label &amp; header size <span id="labelsize-val">100%</span></label>
<input type="range" id="slider-labelsize" min="60" max="140" value="100" step="1">
</div>
<div class="slider-row">
<label>Value text size <span id="textsize-val">100%</span></label>
<input type="range" id="slider-textsize" min="60" max="140" value="100" step="1">
</div>
<div class="slider-row">
<label>Background blur <span id="blur-val">5px</span></label>
<input type="range" id="slider-blur" min="0" max="60" value="5" step="1">
</div>
<div class="toggle-row">
<label class="toggle-item">
<input type="checkbox" id="toggle-header" checked>
<div class="toggle-switch"></div>
<span class="toggle-label">Header line</span>
</label>
<label class="toggle-item">
<input type="checkbox" id="toggle-footer" checked>
<div class="toggle-switch"></div>
<span class="toggle-label">Footer line</span>
</label>
</div>
<button class="btn-regenerate" id="regenerate-btn">↺ Update card</button>
</div>
<a class="ig-link" id="ig-link-bottom" style="display:none; margin-top:16px;" href="https://www.instagram.com/FateOfAstrophilia" target="_blank" rel="noopener">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="5"/>
<circle cx="12" cy="12" r="4"/>
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/>
</svg>
@FateOfAstrophilia
</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/utif2@4.1.0/UTIF.js"></script>
<script>
const isMobile = () => window.matchMedia('(hover: none)').matches;
const dragLayer = document.getElementById('drag-layer');
const gridCanvas = document.getElementById('grid-canvas');
const trashZone = document.getElementById('trash-zone');
const GRID = 40;
let dragItems = [];
let dragState = null;
let customDragPositions = {};
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const outputSection = document.getElementById('output-section');
const canvasWrapper = document.getElementById('canvas-wrapper');
const canvas = document.getElementById('preview-canvas');
const ctx = canvas.getContext('2d');
const downloadBtn = document.getElementById('download-btn');
const uploadBtn = document.getElementById('upload-btn');
const uploadAnyInput = document.getElementById('upload-any-input');
const resetBtn = document.getElementById('reset-btn');
const statusContainer = document.getElementById('status-container');
const cardActions = document.getElementById('card-actions');
const editPanel = document.getElementById('edit-panel');
const fitsNotice = document.getElementById('fits-notice');
const regenerateBtn = document.getElementById('regenerate-btn');
const editFields = {
header: document.getElementById('ef-header'),
camera: document.getElementById('ef-camera'),
telescope: document.getElementById('ef-telescope'),
lensModel: document.getElementById('ef-lens'),
focalLength: document.getElementById('ef-focal'),
aperture: document.getElementById('ef-aperture'),
filter: document.getElementById('ef-filter'),
shutter: document.getElementById('ef-shutter'),
frames: document.getElementById('ef-frames'),
totalExp: document.getElementById('ef-totalexp'),
iso: document.getElementById('ef-iso'),
object: document.getElementById('ef-object'),
location: document.getElementById('ef-location'),
bortle: document.getElementById('ef-bortle'),
date: document.getElementById('ef-date'),
footer: document.getElementById('ef-footer'),
iconColor: document.getElementById('ef-iconcolor'),
textColor: document.getElementById('ef-textcolor'),
};
const editFields2 = {
camera: document.getElementById('ef2-camera'),
telescope: document.getElementById('ef2-telescope'),
lensModel: document.getElementById('ef2-lens'),
focalLength: document.getElementById('ef2-focal'),
aperture: document.getElementById('ef2-aperture'),
filter: document.getElementById('ef2-filter'),
shutter: document.getElementById('ef2-shutter'),
frames: document.getElementById('ef2-frames'),
totalExp: document.getElementById('ef2-totalexp'),
iso: document.getElementById('ef2-iso'),
object: document.getElementById('ef2-object'),
date: document.getElementById('ef2-date'),
};
const efCol1Label = document.getElementById('ef-col1label');
const efCol2Label = document.getElementById('ef-col2label');
const secondCamToggle = document.getElementById('second-cam-toggle');
const secondCamFields = document.getElementById('second-cam-fields');
secondCamToggle.addEventListener('click', () => {
secondCamToggle.classList.toggle('open');
secondCamFields.classList.toggle('open');
});
const tapHint = document.getElementById('tap-hint');
const igLinkTop = document.getElementById('ig-link-top');
const igLinkBottom = document.getElementById('ig-link-bottom');
const sliderTextSize = document.getElementById('slider-textsize');
const sliderLabelSize = document.getElementById('slider-labelsize');
const sliderBlur = document.getElementById('slider-blur');
const toggleHeader = document.getElementById('toggle-header');
const toggleFooter = document.getElementById('toggle-footer');
const textsizeVal = document.getElementById('textsize-val');
const labelsizeVal = document.getElementById('labelsize-val');
const blurVal = document.getElementById('blur-val');
let currentFilename = 'spec-card.jpg';
let currentImg = null;
let currentMeta = {};
let textScale = 1.0;
let labelScale = 1.4;
let blurAmount = 5;
let showHeader = true;
let showFooter = true;
Object.entries(editFields).forEach(([key, input]) => {
if (!input) return;
input.addEventListener('input', () => {
input.classList.toggle('has-value', !!input.value);
if (key === 'camera' && input.value) {
delete input.dataset.hidden;
}
if (key === 'header') {
delete customDragPositions['header'];
}
if (key === 'footer') {
const footerVal = input.value.trim();
if (footerVal) localStorage.setItem('speccard_footer', footerVal);
else localStorage.removeItem('speccard_footer');
}
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
});
Object.values(editFields2).forEach(input => {
if (!input) return;
input.addEventListener('input', () => {
input.classList.toggle('has-value', !!input.value);
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
});
[efCol1Label, efCol2Label].forEach(input => {
if (!input) return;
input.addEventListener('input', () => {
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
});
let iconColor = '#c8a96e';
let textColor = '#e8eaf0';
function showLoading(msg) {
statusContainer.innerHTML = `<div class="loading-state"><div class="spinner"></div><span>${msg}</span></div>`;
canvasWrapper.style.display = 'none';
cardActions.style.display = 'none';
}
function showError(msg) {
statusContainer.innerHTML = `<div class="error-state">${msg}</div>`;
cardActions.style.display = 'flex';
}
function clearStatus() {
statusContainer.innerHTML = '';
}
dropZone.addEventListener('dragover', e => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) processFile(file);
});
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
e.preventDefault();
if (outputSection.style.display && outputSection.style.display !== 'none') {
const file = e.dataTransfer.files[0];
if (!file) return;
const name = file.name.toLowerCase();
const isDataFormat = name.endsWith('.fit') || name.endsWith('.fits') ||
name.endsWith('.cr2') || name.endsWith('.cr3') ||
name.endsWith('.tif') || name.endsWith('.tiff');
if (isDataFormat) {
canvasWrapper.style.display = 'none';
cardActions.style.display = 'none';
processFile(file);
} else {
swapBackground(file);
}
}
});
function swapBackground(file) {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
currentImg = img;
const meta = readEditFields();
renderCard(img, meta);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
fileInput.addEventListener('change', e => {
const file = e.target.files[0];
if (file) processFile(file);
});
dropZone.addEventListener('dblclick', () => fileInput.click());
resetBtn.addEventListener('click', () => {
outputSection.style.display = 'none';
canvasWrapper.style.display = 'none';
cardActions.style.display = 'none';
editPanel.style.display = 'none';
tapHint.style.display = 'none';
dropZone.style.display = '';
igLinkTop.style.display = 'inline-flex';
igLinkBottom.style.display = 'none';
fileInput.value = '';
currentFilename = 'spec-card.jpg';
currentImg = null;
currentMeta = {};
dragItems = [];
dragLayer.innerHTML = '';
customDragPositions = {};
if (editFields.camera) delete editFields.camera.dataset.hidden;
clearStatus();
blurAmount = 5;
iconColor = '#c8a96e';
textColor = '#e8eaf0';
showHeader = true;
showFooter = true;
toggleHeader.checked = true;
toggleFooter.checked = true;
textScale = 1.0;
labelScale = 1.4;
sliderTextSize.value = 100;
sliderLabelSize.value = 140;
sliderBlur.value = 5;
textsizeVal.textContent = '100%';
labelsizeVal.textContent = '140%';
blurVal.textContent = '5px';
Object.values(editFields).forEach(f => { f.value = ''; f.classList.remove('has-value'); });
Object.values(editFields2).forEach(f => { if (f) { f.value = ''; f.classList.remove('has-value'); } });
if (efCol1Label) efCol1Label.value = 'Sky';
if (efCol2Label) efCol2Label.value = 'Foreground';
secondCamToggle.classList.remove('open');
secondCamFields.classList.remove('open');
});
uploadBtn.addEventListener('click', () => {
uploadAnyInput.value = '';
uploadAnyInput.click();
});
uploadAnyInput.addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
if (outputSection.style.display && outputSection.style.display !== 'none') {
const name = file.name.toLowerCase();
const isDataFormat = name.endsWith('.fit') || name.endsWith('.fits') ||
name.endsWith('.cr2') || name.endsWith('.cr3') ||
name.endsWith('.tif') || name.endsWith('.tiff');
if (isDataFormat) {
processFile(file);
} else {
swapBackground(file);
}
} else {
processFile(file);
}
uploadAnyInput.value = '';
});
regenerateBtn.addEventListener('click', () => {
if (!currentImg) return;
const meta = readEditFields();
renderCard(currentImg, meta, Object.keys(customDragPositions).length > 0);
});
sliderTextSize.addEventListener('input', () => {
textScale = sliderTextSize.value / 100;
textsizeVal.textContent = sliderTextSize.value + '%';
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
sliderLabelSize.addEventListener('input', () => {
labelScale = sliderLabelSize.value / 100;
labelsizeVal.textContent = sliderLabelSize.value + '%';
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
sliderBlur.addEventListener('input', () => {
blurAmount = parseInt(sliderBlur.value);
blurVal.textContent = blurAmount + 'px';
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
toggleHeader.addEventListener('change', () => {
showHeader = toggleHeader.checked;
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
toggleFooter.addEventListener('change', () => {
showFooter = toggleFooter.checked;
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
function makeSliderTappable(slider) {
slider.addEventListener('touchstart', e => {
e.preventDefault();
const touch = e.touches[0];
const rect = slider.getBoundingClientRect();
const min = parseFloat(slider.min);
const max = parseFloat(slider.max);
const pct = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
slider.value = Math.round(min + pct * (max - min));
slider.dispatchEvent(new Event('input'));
}, { passive: false });
slider.addEventListener('touchmove', e => {
e.preventDefault();
const touch = e.touches[0];
const rect = slider.getBoundingClientRect();
const min = parseFloat(slider.min);
const max = parseFloat(slider.max);
const pct = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
slider.value = Math.round(min + pct * (max - min));
slider.dispatchEvent(new Event('input'));
}, { passive: false });
}
makeSliderTappable(sliderTextSize);
makeSliderTappable(sliderLabelSize);
makeSliderTappable(sliderBlur);
editFields.iconColor.addEventListener('input', () => {
const val = editFields.iconColor.value.trim();
if (val === '') {
iconColor = '#c8a96e';
} else if (/^#[0-9a-fA-F]{6}$/.test(val)) {
iconColor = val;
}
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
editFields.textColor.addEventListener('input', () => {
const val = editFields.textColor.value.trim();
if (val === '') {
textColor = '#e8eaf0';
} else if (/^#[0-9a-fA-F]{6}$/.test(val)) {
textColor = val;
}
if (currentImg) renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
});
const CARD_REGION_KEYS = [
{ key: 'header', label: 'Header text' },
{ key: 'camera', label: 'Camera / Scope' },
{ key: 'telescope', label: 'Telescope' },
{ key: 'lensModel', label: 'Lens' },
{ key: 'focalLength',label: 'Focal Length' },
{ key: 'aperture', label: 'Aperture' },
{ key: 'filter', label: 'Filter' },
{ key: 'shutter', label: 'Shutter / Exposure' },
{ key: 'frames', label: 'Frames Stacked' },
{ key: 'totalExp', label: 'Total Integration' },
{ key: 'iso', label: 'ISO / Gain' },
{ key: 'object', label: 'Object / Subject' },
{ key: 'location', label: 'Location' },
{ key: 'bortle', label: 'Bortle Scale' },
{ key: 'date', label: 'Date' },
{ key: 'footer', label: 'Footer (IG / website)' },
];
function getCardRegions() {
const SIZE = 1080;
const specs = buildSpecs(readEditFields());
const contentTop = 220;
const contentBottom = SIZE - 210;
const availableH = contentBottom - contentTop;
const twoCol = specs.length > 7;
const regions = [
{ key: 'header', label: 'Header text', yStart: 0, yEnd: 210 / SIZE },
];
if (twoCol) {
const col1 = specs.slice(0, Math.ceil(specs.length / 2));
const col2 = specs.slice(Math.ceil(specs.length / 2));
const colCount = Math.max(col1.length, col2.length);
const maxRowH = 100;
const minRowH = 56;
const rowH = Math.max(minRowH, Math.min(maxRowH, Math.floor(availableH / colCount)));
const totalBlockH = colCount * rowH;
const startY = Math.round(contentTop + (availableH - totalBlockH) / 2);
[col1, col2].forEach(col => {
col.forEach((item, i) => {
const rowTop = startY + i * rowH;
const def = CARD_REGION_KEYS.find(r => r.key === item.key);
regions.push({
key: item.key,
label: def ? def.label : item.label,
yStart: rowTop / SIZE,
yEnd: (rowTop + rowH) / SIZE,
});
});
});
} else {
const maxRowH = 100;
const minRowH = 62;
const rowH = Math.max(minRowH, Math.min(maxRowH, Math.floor(availableH / specs.length)));
const totalBlockH = specs.length * rowH;
const startY = Math.round(contentTop + (availableH - totalBlockH) / 2);
specs.forEach((item, i) => {
const rowTop = startY + i * rowH;
const def = CARD_REGION_KEYS.find(r => r.key === item.key);
regions.push({
key: item.key,
label: def ? def.label : item.label,
yStart: rowTop / SIZE,
yEnd: (rowTop + rowH) / SIZE,
});
});
}
regions.push({ key: 'footer', label: 'Footer (IG / website)', yStart: (SIZE - 210) / SIZE, yEnd: 1 });
return regions;
}
canvas.style.cursor = 'pointer';
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const relY = (e.clientY - rect.top) / rect.height;
const regions = getCardRegions();
const region = regions.find(r => relY >= r.yStart && relY < r.yEnd);
if (!region) return;
showFieldOverlay(region.key, region.label);
});
function showFieldOverlay(key, label) {
const existing = document.getElementById('field-overlay');
if (existing) existing.remove();
const currentVal = editFields[key] ? editFields[key].value : '';
const overlay = document.createElement('div');
overlay.className = 'field-overlay';
overlay.id = 'field-overlay';
overlay.innerHTML = `
<div class="field-overlay-box">
<h4>${label}</h4>
<input type="text" id="overlay-input" value="${currentVal.replace(/"/g, '&quot;')}" placeholder="${editFields[key] ? editFields[key].placeholder : ''}">
<div class="field-overlay-btns">
<button class="cancel" id="overlay-cancel">Cancel</button>
<button class="confirm" id="overlay-confirm">Update card</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = document.getElementById('overlay-input');
input.focus();
input.select();
const close = () => overlay.remove();
input.addEventListener('input', () => {
if (editFields[key] && currentImg) {
editFields[key].value = input.value;
editFields[key].classList.toggle('has-value', !!input.value);
renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
}
});
document.getElementById('overlay-cancel').addEventListener('click', close);
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
const confirm = () => {
const val = input.value.trim();
if (editFields[key]) {
editFields[key].value = val;
editFields[key].classList.toggle('has-value', !!val);
}
const meta = readEditFields();
renderCard(currentImg, meta, Object.keys(customDragPositions).length > 0);
close();
};
document.getElementById('overlay-confirm').addEventListener('click', confirm);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') confirm();
if (e.key === 'Escape') close();
});
}
downloadBtn.addEventListener('click', () => {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const link = document.createElement('a');
link.download = `camera-spec-card-${ts}.jpg`;
link.href = canvas.toDataURL('image/jpeg', 0.92);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
function getFileType(file) {
const name = file.name.toLowerCase();
if (name.endsWith('.cr2') || name.endsWith('.cr3')) return 'raw';
if (name.endsWith('.tif') || name.endsWith('.tiff')) return 'tiff';
if (name.endsWith('.fit') || name.endsWith('.fits')) return 'fits';
return 'standard';
}
function processFile(file) {
currentFilename = file.name || 'spec-card.jpg';
outputSection.style.display = 'flex';
igLinkTop.style.display = 'none';
igLinkBottom.style.display = 'inline-flex';
dropZone.style.display = 'none';
window.scrollTo({ top: outputSection.offsetTop - 32, behavior: 'smooth' });
const type = getFileType(file);
if (type === 'raw') {
showLoading('Decoding RAW file…');
processRaw(file);
} else if (type === 'tiff') {
showLoading('Decoding TIFF…');
processTiff(file);
} else if (type === 'fits') {
showLoading('Reading FITS header…');
processFits(file);
} else {
showLoading('Reading image…');
processStandard(file);
}
}
function processStandard(file) {
const dataReader = new FileReader();
const bufReader = new FileReader();
dataReader.onload = e => {
const img = new Image();
img.onload = () => {
if (currentImg !== null) {
currentImg = img;
clearStatus();
renderCard(img, readEditFields());
return;
}
currentImg = img;
clearStatus();
const bytes = bufReader._bytes;
let metaFromBuffer = null;
if (bytes) {
try { metaFromBuffer = extractExifFromJpegBuffer(bytes); } catch(e) {}
}
const hasBufferMeta = metaFromBuffer && !!(metaFromBuffer.camera || metaFromBuffer.aperture || metaFromBuffer.shutter || metaFromBuffer.iso || metaFromBuffer.focalLength);
if (hasBufferMeta) {
populateEditFields(metaFromBuffer, false);
renderCard(img, metaFromBuffer);
return;
}
let exifDone = false;
try {
EXIF.getData(img, function() {
if (exifDone) return;
exifDone = true;
const exif = EXIF.getAllTags(this);
const meta = extractMeta(exif);
const hasExif = !!(meta.camera || meta.aperture || meta.shutter || meta.iso || meta.focalLength);
if (hasExif) {
populateEditFields(meta, false);
renderCard(img, meta);
} else {
renderCard(img, readEditFields());
}
});
} catch(err) {
exifDone = true;
renderCard(img, readEditFields());
}
setTimeout(() => {
if (!exifDone) {
exifDone = true;
renderCard(img, readEditFields());
}
}, 1500);
};
img.onerror = () => showError('Could not decode this image. Try saving it as a JPEG or PNG first.');
img.src = e.target.result;
};
bufReader.onload = e => {
bufReader._bytes = new Uint8Array(e.target.result);
};
bufReader.readAsArrayBuffer(file);
dataReader.readAsDataURL(file);
}
function extractExifFromJpegBuffer(bytes) {
if (bytes[0] !== 0xFF || bytes[1] !== 0xD8) return null;
let offset = 2;
while (offset < bytes.length - 4) {
if (bytes[offset] !== 0xFF) break;
const marker = bytes[offset + 1];
const segLen = (bytes[offset + 2] << 8) | bytes[offset + 3];
if (marker === 0xE1) {
if (bytes[offset+4]===0x45 && bytes[offset+5]===0x78 &&
bytes[offset+6]===0x69 && bytes[offset+7]===0x66 &&
bytes[offset+8]===0x00 && bytes[offset+9]===0x00) {
const tiffStart = offset + 10;
const seg = bytes.slice(tiffStart, offset + 2 + segLen);
const le = seg[0] === 0x49;
const ifd0 = readUint32_b(seg, 4, le);
const exifRaw = {};
parseIfdFull(seg, 0, ifd0, le, exifRaw, 0);
return buildMetaFromRaw(exifRaw);
}
}
if (marker === 0xDA) break;
offset += 2 + segLen;
}
return null;
}
function readUint16_b(b, off, le) {
if (off + 1 >= b.length) return 0;
return le ? b[off] | (b[off+1] << 8) : (b[off] << 8) | b[off+1];
}
function readUint32_b(b, off, le) {
if (off + 3 >= b.length) return 0;
return le
? (b[off] | (b[off+1]<<8) | (b[off+2]<<16) | (b[off+3]<<24)) >>> 0
: ((b[off]<<24) | (b[off+1]<<16) | (b[off+2]<<8) | b[off+3]) >>> 0;
}
function readAscii_b(b, off, len) {
let s = '';
for (let i = 0; i < len && off+i < b.length && b[off+i] !== 0; i++) s += String.fromCharCode(b[off+i]);
return s.trim();
}
function readRational_b(b, off, le) {
const num = readUint32_b(b, off, le);
const den = readUint32_b(b, off+4, le);
return den !== 0 ? num / den : 0;
}
function parseIfdFull(b, base, ifdOff, le, result, depth) {
if (depth > 4 || ifdOff + 2 > b.length) return;
const count = readUint16_b(b, ifdOff, le);
if (count > 300 || count === 0) return;
for (let i = 0; i < count; i++) {
const e = ifdOff + 2 + i * 12;
if (e + 12 > b.length) break;
const tag = readUint16_b(b, e, le);
const type = readUint16_b(b, e+2, le);
const cnt = readUint32_b(b, e+4, le);
const valOff = e + 8;
const typeSizes = [0,1,1,2,4,8,1,1,2,4,8,4,8];
const tsz = (type < typeSizes.length ? typeSizes[type] : 1);
const dsize = tsz * cnt;
const doff = dsize > 4 ? base + readUint32_b(b, valOff, le) : valOff;
if (doff + dsize > b.length) continue;
if (tag === 0x010F) result.Make = readAscii_b(b, doff, cnt);
else if (tag === 0x0110) result.Model = readAscii_b(b, doff, cnt);
else if (tag === 0x9003 || tag === 0x0132) result.DateTimeOriginal = readAscii_b(b, doff, cnt);
else if (tag === 0x829A && type === 5) result.ExposureTime = readRational_b(b, doff, le);
else if (tag === 0x829D && type === 5) result.FNumber = readRational_b(b, doff, le);
else if (tag === 0x9202 && type === 5) result.ApertureValue = readRational_b(b, doff, le);
else if (tag === 0x8827) result.ISOSpeedRatings = type === 3 ? readUint16_b(b, doff, le) : readUint32_b(b, doff, le);
else if (tag === 0x920A && type === 5) result.FocalLength = readRational_b(b, doff, le);
else if (tag === 0xA405 && type === 3) result.FocalLengthIn35mm = readUint16_b(b, doff, le);
else if (tag === 0xA434) result.LensModel = readAscii_b(b, doff, cnt);
else if (tag === 0x8769 && type === 4) parseIfdFull(b, base, base + readUint32_b(b, valOff, le), le, result, depth + 1);
else if (tag === 0xA005 && type === 4) parseIfdFull(b, base, base + readUint32_b(b, valOff, le), le, result, depth + 1);
}
}
function buildMetaFromRaw(exif) {
const make = (exif.Make || '').replace(/\0/g, '').trim();
const model = (exif.Model || '').replace(/\0/g, '').trim();
const camera = model.toLowerCase().startsWith(make.toLowerCase()) ? model : [make, model].filter(Boolean).join(' ').trim() || null;
const lensModel = exif.LensModel || null;
let focalLength = null;
if (exif.FocalLength > 0) focalLength = Math.round(exif.FocalLength) + 'mm';
else if (exif.FocalLengthIn35mm > 0) focalLength = exif.FocalLengthIn35mm + 'mm';
let aperture = null;
if (exif.FNumber > 0) aperture = 'f/' + exif.FNumber.toFixed(1);
else if (exif.ApertureValue > 0) aperture = 'f/' + Math.pow(2, exif.ApertureValue / 2).toFixed(1);
let shutter = null;
if (exif.ExposureTime > 0) {
const val = exif.ExposureTime;
shutter = val >= 1 ? val.toFixed(0) + 's' : '1/' + Math.round(1 / val) + 's';
}
let iso = null;
if (exif.ISOSpeedRatings) iso = 'ISO ' + exif.ISOSpeedRatings;
let date = null;
if (exif.DateTimeOriginal) {
const parts = exif.DateTimeOriginal.split(' ');
if (parts[0]) {
const parsed = new Date(parts[0].replace(/:/g, '-'));
if (!isNaN(parsed)) date = parsed.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
}
return { camera, lensModel, focalLength, aperture, shutter, iso, date };
}
function extractTiffStringTag(bytes, targetTag) {
if (bytes.length < 8) return null;
const le = bytes[0] === 0x49;
const r16 = o => le ? bytes[o]|(bytes[o+1]<<8) : (bytes[o]<<8)|bytes[o+1];
const r32 = o => (le ? bytes[o]|(bytes[o+1]<<8)|(bytes[o+2]<<16)|(bytes[o+3]<<24) : (bytes[o]<<24)|(bytes[o+1]<<16)|(bytes[o+2]<<8)|bytes[o+3]) >>> 0;
const ifd0 = r32(4);
if (ifd0 + 2 > bytes.length) return null;
const count = r16(ifd0);
for (let i = 0; i < Math.min(count, 64); i++) {
const e = ifd0 + 2 + i * 12;
if (e + 12 > bytes.length) break;
if (r16(e) === targetTag && r16(e+2) === 2) {
const cnt = r32(e+4);
const doff = cnt > 4 ? r32(e+8) : e+8;
if (doff + cnt > bytes.length) return null;
return new TextDecoder('latin1').decode(bytes.slice(doff, doff+cnt)).replace(/\0/g,'').trim();
}
}
return null;
}
function processTiff(file) {
const reader = new FileReader();
reader.onload = e => {
try {
const buffer = e.target.result;
const ifds = UTIF.decode(buffer);
if (!ifds || ifds.length === 0) throw new Error('No IFDs found');
UTIF.decodeImage(buffer, ifds[0]);
const ifd = ifds[0];
const w = ifd.t256 ? (Array.isArray(ifd.t256) ? ifd.t256[0] : ifd.t256) : 0;
const h = ifd.t257 ? (Array.isArray(ifd.t257) ? ifd.t257[0] : ifd.t257) : 0;
if (!w || !h) throw new Error('Could not read image dimensions');
const rgba = UTIF.toRGBA8(ifd);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = w;
tempCanvas.height = h;
const tempCtx = tempCanvas.getContext('2d');
const imgData = tempCtx.createImageData(w, h);
imgData.data.set(rgba);
tempCtx.putImageData(imgData, 0, 0);
const meta = (() => {
const tiffMeta = extractMetaFromTiffIfd(ifd);
const hasData = !!(tiffMeta.camera || tiffMeta.shutter || tiffMeta.iso || tiffMeta.focalLength);
if (hasData) return tiffMeta;
const descBytes = new Uint8Array(buffer);
const descTag = extractTiffStringTag(descBytes, 0x010E);
if (descTag && descTag.includes('SIMPLE')) {
try { return parseFitsHeader(descTag); } catch(e) {}
}
return tiffMeta;
})();
const img = new Image();
img.onload = () => {
const cardIsShowing = currentImg !== null;
currentImg = img;
clearStatus();
if (cardIsShowing) {
renderCard(img, readEditFields());
} else {
populateEditFields(meta, false);
renderCard(img, meta);
}
};
img.src = tempCanvas.toDataURL();
} catch (err) {
showError(`TIFF decode failed: ${err.message}. If this is a 32-bit float TIFF from Siril, export as 16-bit or 8-bit TIFF first.`);
}
};
reader.readAsArrayBuffer(file);
}
function processRaw(file) {
const reader = new FileReader();
reader.onload = e => {
try {
const buffer = e.target.result;
const bytes = new Uint8Array(buffer);
const rawExif = extractExifFromBuffer(bytes);
const meta = buildMetaFromRaw(rawExif);
const { offset: jpegOffset, end: jpegEnd } = findEmbeddedJpeg(bytes);
if (jpegOffset === -1) throw new Error('No embedded preview found in this RAW file.');
const jpegBytes = bytes.slice(jpegOffset, jpegEnd);
const blob = new Blob([jpegBytes], { type: 'image/jpeg' });
createImageBitmap(blob).then(bitmap => {
const offscreen = document.createElement('canvas');
offscreen.width = bitmap.width;
offscreen.height = bitmap.height;
offscreen.getContext('2d').drawImage(bitmap, 0, 0);
const img = new Image();
img.onload = () => {
try {
const cardIsShowing = currentImg !== null;
currentImg = img;
clearStatus();
if (cardIsShowing) {
renderCard(img, readEditFields());
} else {
populateEditFields(meta, false);
renderCard(img, meta);
}
} catch(err) {
console.error('renderCard error:', err);
showError('RAW render failed: ' + err.message);
}
};
img.src = offscreen.toDataURL('image/jpeg', 0.92);
}).catch(err => {
showError('Found a RAW file but could not extract the embedded preview.');
});
} catch (err) {
showError(`RAW decode failed: ${err.message}`);
}
};
reader.readAsArrayBuffer(file);
}
function processFits(file) {
const reader = new FileReader();
reader.onload = e => {
try {
const buffer = e.target.result;
const text = new TextDecoder('ascii').decode(new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 23040)));
const meta = parseFitsHeader(text);
clearStatus();
populateEditFields(meta, true);
if (!currentImg) {
fitsNotice.style.display = 'block';
fitsNotice.textContent = 'FITS files contain no viewable image preview. The card background will be a blurred gradient. Drop an image onto the card or use the background zone below to set one.';
currentImg = makeFitsPlaceholderImage(meta);
}
renderCard(currentImg, readEditFields());
} catch (err) {
showError(`FITS read failed: ${err.message}`);
}
};
reader.readAsArrayBuffer(file);
}
function parseFitsHeader(text) {
const meta = {};
const isFixed = text.length >= 80 && !text.slice(0, 80).includes('\n');
let cards = [];
if (isFixed) {
for (let i = 0; i + 80 <= text.length; i += 80) cards.push(text.slice(i, i + 80));
} else {
cards = text.split('\n').map(l => l.padEnd(80));
}
const get = key => {
const card = cards.find(c => c.slice(0,8).trim() === key);
if (!card) return null;
const val = card.slice(10).split('/')[0].trim().replace(/^'(.*?)'$/, '$1').trim();
return val || null;
};
const getNum = key => {
const v = get(key);
return v !== null ? parseFloat(v) : null;
};
const instrume = get('INSTRUME');
const telescop = get('TELESCOP');
meta.camera = instrume || null;
meta.telescope = (instrume && telescop && instrume !== telescop) ? telescop : (!instrume ? telescop : null);
meta.lensModel = get('LENS') || get('OPTICS') || null;
const fl = getNum('FOCALLEN') || getNum('FOCAL');
meta.focalLength = fl ? fl.toFixed(0) + 'mm' : null;
const fratio = getNum('FOCRATIO') || getNum('APERTURE');
meta.aperture = fratio ? 'f/' + fratio.toFixed(1) : null;
meta.filter = get('FILTER') || get('FILT') || null;
const exptime = getNum('EXPTIME') || getNum('EXPOSURE');
if (exptime !== null) {
meta.shutter = exptime >= 1 ? exptime.toFixed(0) + 's' : '1/' + Math.round(1/exptime) + 's';
} else {
meta.shutter = null;
}
const stackcnt = getNum('STACKCNT') || getNum('NCOMBINE') || getNum('NFRAMES');
meta.frames = stackcnt ? stackcnt + ' frames' : null;
const totalexp = getNum('TOTALEXP') || getNum('LIVETIME');
if (totalexp !== null) {
const h = Math.floor(totalexp / 3600);
const m = Math.floor((totalexp % 3600) / 60);
const s = Math.round(totalexp % 60);
if (h > 0) meta.totalExp = `${h}h ${m}min`;
else if (m > 0) meta.totalExp = `${m}min ${s}s`;
else meta.totalExp = `${s}s`;
} else {
meta.totalExp = null;
}
const gain = getNum('GAIN');
const iso = getNum('ISO') || getNum('ISOSPEED');
if (iso !== null) meta.iso = 'ISO ' + iso;
else if (gain !== null) meta.iso = 'Gain ' + gain;
else meta.iso = null;
meta.object = get('OBJECT') || get('TARGET') || null;
const lat = get('SITELAT') || get('OBSLAT');
const lon = get('SITELONG') || get('OBSLONG');
meta.location = (lat && lon) ? `${parseFloat(lat).toFixed(2)}°, ${parseFloat(lon).toFixed(2)}°` : null;
const bortle = getNum('BORTLE');
const sqm = getNum('SQMVALUE') || getNum('SQM');
if (bortle !== null) meta.bortle = 'Bortle ' + bortle;
else if (sqm !== null) meta.bortle = 'SQM ' + sqm.toFixed(1);
else meta.bortle = null;
const dateObs = get('DATE-OBS') || get('DATE');
if (dateObs) {
const parsed = new Date(dateObs.split('T')[0]);
meta.date = isNaN(parsed) ? dateObs : parsed.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} else {
meta.date = null;
}
return meta;
}
function makeFitsPlaceholderImage(meta) {
const offscreen = document.createElement('canvas');
offscreen.width = 1080;
offscreen.height = 1080;
const c = offscreen.getContext('2d');
const grad = c.createRadialGradient(540, 540, 0, 540, 540, 760);
grad.addColorStop(0, '#1a1060');
grad.addColorStop(0.4, '#0d0830');
grad.addColorStop(1, '#04030f');
c.fillStyle = grad;
c.fillRect(0, 0, 1080, 1080);
const img = new Image();
img.src = offscreen.toDataURL();
return img;
}
function populateEditFields(meta, isFits) {
fitsNotice.style.display = 'none';
const savedFooter = localStorage.getItem('speccard_footer');
const currentFooterVal = editFields.footer ? editFields.footer.value.trim() : '';
const currentHeaderVal = editFields.header ? editFields.header.value.trim() : '';
if (!meta.footer) meta.footer = currentFooterVal || savedFooter || '@FateOfAstrophilia';
if (!meta.header) meta.header = currentHeaderVal || 'SPEC CARD';
currentMeta = meta;
const specs = buildSpecs(meta);
const autoScale = specs.length <= 7 ? 1.4 : 1.0;
textScale = 1.0;
labelScale = autoScale;
sliderTextSize.value = 100;
sliderLabelSize.value = Math.round(autoScale * 100);
textsizeVal.textContent = '100%';
labelsizeVal.textContent = Math.round(autoScale * 100) + '%';
Object.entries(editFields).forEach(([key, input]) => {
const val = meta[key] || '';
input.value = val;
input.classList.toggle('has-value', !!val);
});
editPanel.style.display = 'block';
}
function readEditFields() {
const meta = {};
Object.entries(editFields).forEach(([key, input]) => {
meta[key] = input.value.trim() || null;
});
return meta;
}
function readEditFields2() {
const meta = {};
Object.entries(editFields2).forEach(([key, input]) => {
meta[key] = input.value.trim() || null;
});
return meta;
}
function hasDualColumn() {
return Object.values(editFields2).some(input => input && input.value.trim() !== '');
}
function findEmbeddedJpeg(bytes) {
let bestOffset = -1;
let bestEnd = -1;
let bestSize = 0;
const MAX_PREVIEW = 8 * 1024 * 1024;
let i = 0;
while (i < bytes.length - 3) {
if (bytes[i] === 0xFF && bytes[i+1] === 0xD8 && bytes[i+2] === 0xFF) {
if ((bytes[i+3] === 0xE0 || bytes[i+3] === 0xE1 || bytes[i+3] === 0xDB || bytes[i+3] === 0xC0 || bytes[i+3] === 0xC4) && i > 100) {
let end = -1;
const limit = Math.min(i + MAX_PREVIEW, bytes.length - 1);
for (let j = i + 2; j < limit; j++) {
if (bytes[j] === 0xFF && bytes[j+1] === 0xD9) { end = j + 2; break; }
}
if (end !== -1) {
const size = end - i;
if (size > bestSize) { bestSize = size; bestOffset = i; bestEnd = end; }
i = end;
continue;
}
}
}
i++;
}
return { offset: bestOffset, end: bestEnd };
}
function readUint16(bytes, offset, le) {
return le ? bytes[offset] | (bytes[offset+1] << 8) : (bytes[offset] << 8) | bytes[offset+1];
}
function readUint32(bytes, offset, le) {
return le
? bytes[offset] | (bytes[offset+1] << 8) | (bytes[offset+2] << 16) | (bytes[offset+3] << 24)
: (bytes[offset] << 24) | (bytes[offset+1] << 16) | (bytes[offset+2] << 8) | bytes[offset+3];
}
function readAscii(bytes, offset, length) {
let s = '';
for (let i = 0; i < length && bytes[offset+i] !== 0; i++) s += String.fromCharCode(bytes[offset+i]);
return s.trim();
}
function readRational(bytes, offset, le) {
const num = readUint32(bytes, offset, le);
const den = readUint32(bytes, offset+4, le);
return den !== 0 ? num / den : 0;
}
function readSRational(bytes, offset, le) {
const num = readUint32(bytes, offset, le) | 0;
const den = readUint32(bytes, offset+4, le) | 0;
return den !== 0 ? num / den : 0;
}
function extractExifFromBuffer(bytes) {
const result = {};
let exifStart = -1;
for (let i = 0; i < Math.min(bytes.length - 6, 131072); i++) {
if (bytes[i]===0x45 && bytes[i+1]===0x78 && bytes[i+2]===0x69 &&
bytes[i+3]===0x66 && bytes[i+4]===0x00 && bytes[i+5]===0x00) {
exifStart = i + 6; break;
}
}
if (exifStart === -1) {
if (bytes[0] === 0x49 && bytes[1] === 0x49) exifStart = 0;
else if (bytes[0] === 0x4D && bytes[1] === 0x4D) exifStart = 0;
}
if (exifStart === -1) return result;
const base = exifStart;
const le = bytes[base] === 0x49;
const ifd0Offset = readUint32(bytes, base + 4, le);
parseIfd(bytes, base, base + ifd0Offset, le, result, 0);
return result;
}
function parseIfd(bytes, base, ifdOffset, le, result, depth) {
if (depth > 4 || ifdOffset + 2 > bytes.length) return;
const count = readUint16(bytes, ifdOffset, le);
if (count > 300 || count === 0) return;
for (let i = 0; i < count; i++) {
const entryOffset = ifdOffset + 2 + i * 12;
if (entryOffset + 12 > bytes.length) break;
const tag = readUint16(bytes, entryOffset, le);
const type = readUint16(bytes, entryOffset + 2, le);
const count2 = readUint32(bytes, entryOffset + 4, le);
const valueOffset = entryOffset + 8;
const typeSizes = [0,1,1,2,4,8,1,1,2,4,8,4,8];
const typeSize = type < typeSizes.length ? typeSizes[type] : 1;
const dataSize = typeSize * count2;
const dataOffset = dataSize > 4 ? base + readUint32(bytes, valueOffset, le) : valueOffset;
if (dataOffset + dataSize > bytes.length) continue;
if (tag === 0x010F) result.Make = readAscii(bytes, dataOffset, count2);
else if (tag === 0x0110) result.Model = readAscii(bytes, dataOffset, count2);
else if (tag === 0x9003 || tag === 0x0132) result.DateTimeOriginal = readAscii(bytes, dataOffset, count2);
else if (tag === 0x829A && type === 5) result.ExposureTime = readRational(bytes, dataOffset, le);
else if (tag === 0x829D && type === 5) {
const fn = readRational(bytes, dataOffset, le);
if (fn > 0) result.FNumber = fn;
}
else if (tag === 0x9202 && type === 5) {
const av = readSRational(bytes, dataOffset, le);
if (av > 0 && av < 32) result.ApertureValue = av;
}
else if (tag === 0x8827) result.ISOSpeedRatings = type === 3 ? readUint16(bytes, dataOffset, le) : readUint32(bytes, dataOffset, le);
else if (tag === 0x920A && type === 5) result.FocalLength = readRational(bytes, dataOffset, le);
else if (tag === 0xA405 && type === 3) result.FocalLengthIn35mm = readUint16(bytes, dataOffset, le);
else if (tag === 0xA434) result.LensModel = readAscii(bytes, dataOffset, count2);
else if (tag === 0x8769 && type === 4) {
const subOffset = base + readUint32(bytes, valueOffset, le);
parseIfd(bytes, base, subOffset, le, result, depth + 1);
}
else if (tag === 0xA005 && type === 4) {
const subOffset = base + readUint32(bytes, valueOffset, le);
parseIfd(bytes, base, subOffset, le, result, depth + 1);
}
}
}
function extractMetaFromTiffIfd(ifd) {
const result = {};
const getString = tag => {
const val = ifd[tag];
if (!val) return null;
if (Array.isArray(val)) return val.map(c => String.fromCharCode(c)).join('').replace(/\0/g,'').trim();
return String(val).trim();
};
const getRational = tag => {
const val = ifd[tag];
if (!val) return null;
if (Array.isArray(val) && val.length >= 2) return val[0] / val[1];
return typeof val === 'number' ? val : null;
};
const make = getString('t271') || '';
const model = getString('t272') || '';
result.camera = model.toLowerCase().startsWith(make.toLowerCase()) ? model : [make, model].filter(Boolean).join(' ').trim() || null;
result.lensModel = getString('ta434') || getString('t42036') || null;
const fl = getRational('ta920a') || getRational('t37386');
result.focalLength = fl ? fl.toFixed(0) + 'mm' : null;
const fn = getRational('ta829d') || getRational('t33437');
result.aperture = fn ? 'f/' + fn.toFixed(1) : null;
const et = getRational('ta829a') || getRational('t33434');
result.shutter = et !== null ? (et >= 1 ? et.toFixed(0) + 's' : '1/' + Math.round(1/et) + 's') : null;
const iso = ifd['ta8827'] || ifd['t34855'];
result.iso = iso ? 'ISO ' + (Array.isArray(iso) ? iso[0] : iso) : null;
const dateRaw = getString('ta9003') || getString('t306');
if (dateRaw) {
const parts = dateRaw.split(' ');
if (parts[0]) {
const d = parts[0].replace(/:/g, '-');
const parsed = new Date(d);
result.date = isNaN(parsed) ? null : parsed.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
}
return result;
}
function extractMeta(exif) {
const make = exif.Make || '';
const model = exif.Model || '';
const camera = model.toLowerCase().startsWith(make.toLowerCase()) ? model : [make, model].filter(Boolean).join(' ').trim() || null;
const lensModel = exif.LensModel || exif.Lens || null;
let focalLength = null;
if (exif.FocalLength) {
const fl = exif.FocalLength;
const flVal = typeof fl === 'object' ? (fl.numerator / fl.denominator) : fl;
if (flVal > 0) focalLength = flVal.toFixed(0) + 'mm';
}
if (!focalLength && exif.FocalLengthIn35mmFilm) {
focalLength = exif.FocalLengthIn35mmFilm + 'mm';
}
let aperture = null;
if (exif.FNumber) {
const fn = exif.FNumber;
const fnVal = typeof fn === 'object' ? (fn.numerator / fn.denominator) : fn;
if (fnVal > 0) aperture = 'f/' + fnVal.toFixed(1);
}
if (!aperture && exif.ApertureValue) {
const av = exif.ApertureValue;
const raw = typeof av === 'object' ? (av.numerator / av.denominator) : av;
if (raw > 0 && raw < 32) aperture = 'f/' + Math.pow(2, raw / 2).toFixed(1);
}
let shutter = null;
if (exif.ExposureTime) {
const et = exif.ExposureTime;
const val = typeof et === 'object' ? (et.numerator / et.denominator) : et;
if (val > 0) shutter = val >= 1 ? val.toFixed(0) + 's' : '1/' + Math.round(1 / val) + 's';
}
let iso = null;
if (exif.ISOSpeedRatings) {
iso = 'ISO ' + (Array.isArray(exif.ISOSpeedRatings) ? exif.ISOSpeedRatings[0] : exif.ISOSpeedRatings);
}
let date = null;
if (exif.DateTimeOriginal) {
const raw = exif.DateTimeOriginal;
const parts = raw.split(' ');
if (parts[0]) {
const d = parts[0].replace(/:/g, '-');
const parsed = new Date(d);
if (!isNaN(parsed)) date = parsed.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
}
return { camera, lensModel, focalLength, aperture, shutter, iso, date };
}
function renderCard(img, meta, useCustomPositions) {
const SIZE = 1080;
canvas.width = SIZE;
canvas.height = SIZE;
ctx.clearRect(0, 0, SIZE, SIZE);
ctx.filter = blurAmount > 0 ? `blur(${blurAmount}px)` : 'none';
ctx.drawImage(img, -40, -40, SIZE + 80, SIZE + 80);
ctx.filter = 'none';
ctx.fillStyle = 'rgba(8, 10, 16, 0.55)';
ctx.fillRect(0, 0, SIZE, SIZE);
const specs = buildSpecs(meta);
const headerFontSize = Math.round(22 * labelScale);
const headerText = meta.header ? meta.header.trim() : '';
if (headerText) {
const headerDrag = useCustomPositions && customDragPositions['header'];
const headerX = headerDrag ? headerDrag.canvasX + 200 : SIZE / 2;
const headerY = headerDrag ? headerDrag.canvasY + 36 : 180;
ctx.textAlign = 'center';
ctx.fillStyle = textColor + '59';
ctx.font = `400 ${headerFontSize}px "Space Mono", monospace`;
ctx.fillText(headerText, headerX, headerY);
}
if (showHeader) {
const headerBarDrag = useCustomPositions && customDragPositions['header-bar'];
const headerLineY = headerBarDrag ? headerBarDrag.canvasY + 8 : 200;
ctx.strokeStyle = iconColor + '59';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(80, headerLineY);
ctx.lineTo(SIZE - 80, headerLineY);
ctx.stroke();
ctx.fillStyle = iconColor + '33';
ctx.beginPath();
ctx.arc(SIZE / 2, headerLineY, 3, 0, Math.PI * 2);
ctx.fill();
}
const contentTop = 220;
const contentBottom = SIZE - 210;
const availableH = contentBottom - contentTop;
const dualMode = hasDualColumn();
const twoCol = !dualMode && specs.length > 7;
if (dualMode) {
const meta2 = readEditFields2();
const specs2 = buildSpecs(meta2);
const col1Label = (efCol1Label && efCol1Label.value.trim()) || 'Sky';
const col2Label = (efCol2Label && efCol2Label.value.trim()) || 'Foreground';
const colCount = Math.max(specs.length, specs2.length);
const maxRowH = 90;
const minRowH = 48;
const rowH = Math.max(minRowH, Math.min(maxRowH, Math.floor((availableH - 60) / colCount)));
const scale = rowH / maxRowH;
const iconSize = Math.round(26 * scale);
const labelSize = Math.max(8, Math.round(10 * scale * labelScale));
const valueSize = Math.max(13, Math.round(20 * scale * textScale));
const totalBlockH = colCount * rowH;
const startY = Math.round(contentTop + 55 + (availableH - 55 - totalBlockH) / 2);
const colContentW = 420;
const colGap = 40;
const totalW = colContentW * 2 + colGap;
const col1X = SIZE / 2 - totalW / 2;
const col2X = col1X + colContentW + colGap;
const colLabelFontSize = Math.round(28 * labelScale);
ctx.font = `600 ${colLabelFontSize}px "Space Grotesk", sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = textColor + '99';
ctx.fillText(col1Label, col1X + colContentW / 2, contentTop + 38);
ctx.fillText(col2Label, col2X + colContentW / 2, contentTop + 38);
ctx.strokeStyle = textColor + '22';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(col1X, contentTop + 48);
ctx.lineTo(col1X + colContentW, contentTop + 48);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(col2X, contentTop + 48);
ctx.lineTo(col2X + colContentW, contentTop + 48);
ctx.stroke();
ctx.strokeStyle = 'rgba(232,234,240,0.08)';
ctx.beginPath();
ctx.moveTo(SIZE / 2, contentTop + 48);
ctx.lineTo(SIZE / 2, startY + totalBlockH - rowH * 0.3);
ctx.stroke();
[specs, specs2].forEach((col, colIdx) => {
const colX = colIdx === 0 ? col1X : col2X;
col.forEach((item, i) => {
const key = colIdx === 0 ? item.key : 'col2_' + item.key;
const custom = useCustomPositions && customDragPositions[key];
const drawX = custom ? custom.canvasX : colX;
const drawY = custom ? custom.canvasY : startY + i * rowH;
drawIcon(ctx, item.icon, drawX, drawY + Math.round(rowH * 0.1), iconSize);
ctx.textAlign = 'left';
ctx.font = `500 ${labelSize}px "Space Mono", monospace`;
ctx.fillStyle = textColor + '66';
ctx.fillText(item.label.toUpperCase(), drawX + iconSize + 10, drawY + Math.round(rowH * 0.32));
ctx.font = `bold ${valueSize}px "Space Grotesk", sans-serif`;
ctx.fillStyle = textColor;
ctx.fillText(item.value, drawX + iconSize + 10, drawY + Math.round(rowH * 0.72), colContentW - iconSize - 16);
});
});
} else if (twoCol) {
const col1 = specs.slice(0, Math.ceil(specs.length / 2));
const col2 = specs.slice(Math.ceil(specs.length / 2));
const colCount = Math.max(col1.length, col2.length);
const maxRowH = 100;
const minRowH = 56;
const rowH = Math.max(minRowH, Math.min(maxRowH, Math.floor(availableH / colCount)));
const scale = rowH / maxRowH;
const iconSize = Math.round(28 * scale);
const labelSize = Math.max(8, Math.round(11 * scale * labelScale));
const valueSize = Math.max(14, Math.round(22 * scale * textScale));
const totalBlockH = colCount * rowH;
const startY = Math.round(contentTop + (availableH - totalBlockH) / 2);
const colContentW = 380;
const colGap = 60;
const totalTwoColW = colContentW * 2 + colGap;
const col1X = SIZE / 2 - totalTwoColW / 2 + 60;
const col2X = col1X + colContentW + colGap;
ctx.strokeStyle = 'rgba(232,234,240,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(SIZE / 2, startY);
ctx.lineTo(SIZE / 2, startY + totalBlockH - rowH * 0.3);
ctx.stroke();
[col1, col2].forEach((col, colIdx) => {
const colX = colIdx === 0 ? col1X : col2X;
col.forEach((item, i) => {
const defaultY = startY + i * rowH;
const custom = useCustomPositions && customDragPositions[item.key];
const drawX = custom ? custom.canvasX : colX;
const drawY = custom ? custom.canvasY : defaultY;
drawIcon(ctx, item.icon, drawX, drawY + Math.round(rowH * 0.1), iconSize);
ctx.textAlign = 'left';
ctx.font = `500 ${labelSize}px "Space Mono", monospace`;
ctx.fillStyle = textColor + '66';
ctx.fillText(item.label.toUpperCase(), drawX + iconSize + 12, drawY + Math.round(rowH * 0.3));
ctx.font = `bold ${valueSize}px "Space Grotesk", sans-serif`;
ctx.fillStyle = textColor;
const maxW = colContentW - iconSize - 20;
ctx.fillText(item.value, drawX + iconSize + 12, drawY + Math.round(rowH * 0.68), maxW);
});
});
} else {
const maxRowH = 100;
const minRowH = 62;
const rowH = Math.max(minRowH, Math.min(maxRowH, Math.floor(availableH / specs.length)));
const scale = rowH / maxRowH;
const iconSize = Math.round(34 * scale);
const labelSize = Math.max(9, Math.round(12 * scale * labelScale));
const valueSize = Math.max(16, Math.round(26 * scale * textScale));
const blockW = 300;
const x = SIZE / 2 - blockW / 2;
const totalBlockH = specs.length * rowH;
const startY = Math.round(contentTop + (availableH - totalBlockH) / 2);
specs.forEach((item, i) => {
const defaultY = startY + i * rowH;
const custom = useCustomPositions && customDragPositions[item.key];
const drawX = custom ? custom.canvasX : x;
const drawY = custom ? custom.canvasY : defaultY;
drawIcon(ctx, item.icon, drawX, drawY + Math.round(rowH * 0.1), iconSize);
ctx.textAlign = 'left';
ctx.font = `500 ${labelSize}px "Space Mono", monospace`;
ctx.fillStyle = textColor + '66';
ctx.fillText(item.label.toUpperCase(), drawX + iconSize + 16, drawY + Math.round(rowH * 0.3));
ctx.font = `bold ${valueSize}px "Space Grotesk", sans-serif`;
ctx.fillStyle = textColor;
ctx.fillText(item.value, drawX + iconSize + 16, drawY + Math.round(rowH * 0.68));
});
}
const footerLineY = SIZE - 200;
const footerTextY = SIZE - 152;
const footerFontSize = Math.round(20 * labelScale);
const footerText = (meta.footer || '').trim();
if (showFooter) {
const footerBarDrag = useCustomPositions && customDragPositions['footer-bar'];
const footerBarY = footerBarDrag ? footerBarDrag.canvasY + 8 : footerLineY;
ctx.strokeStyle = iconColor + '4d';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(80, footerBarY);
ctx.lineTo(SIZE - 80, footerBarY);
ctx.stroke();
ctx.fillStyle = iconColor + '2e';
const dotR = 3;
ctx.beginPath();
ctx.arc(SIZE / 2, footerBarY, dotR, 0, Math.PI * 2);
ctx.fill();
}
if (footerText) {
const footerDrag = useCustomPositions && customDragPositions['footer'];
const footerDrawY = footerDrag ? footerDrag.canvasY + 36 : footerTextY;
const footerCenterX = footerDrag ? footerDrag.canvasX + 240 : SIZE / 2;
ctx.font = `300 ${footerFontSize}px "Space Grotesk", sans-serif`;
const ftextW = ctx.measureText(footerText).width;
const ficonSz = Math.round(24 * labelScale);
const fgap = 10;
const ftotalW = ficonSz + fgap + ftextW;
const ficonX = footerCenterX - ftotalW / 2;
const ftextX = ficonX + ficonSz + fgap;
const fim = footerDrawY - ficonSz * 0.3;
const fiy = fim - ficonSz / 2;
ctx.save();
ctx.strokeStyle = iconColor + '66';
ctx.lineWidth = 1.3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const fic = ficonX + ficonSz / 2;
const fir = ficonSz / 2;
ctx.beginPath();
ctx.roundRect(ficonX + 1, fim - fir * 0.55, ficonSz - 2, ficonSz * 0.78, 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(fic, fim + fir * 0.06, fir * 0.3, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.roundRect(ficonX + ficonSz * 0.27, fim - fir * 0.55 - fir * 0.22, ficonSz * 0.28, fir * 0.26, 1);
ctx.stroke();
ctx.restore();
ctx.textAlign = 'left';
ctx.font = `300 ${footerFontSize}px "Space Grotesk", sans-serif`;
ctx.fillStyle = textColor + '66';
ctx.fillText(footerText, ftextX, footerDrawY);
}
canvasWrapper.style.display = 'block';
cardActions.style.display = 'flex';
tapHint.style.display = isMobile() ? 'block' : 'none';
if (editPanel.style.display === 'none') editPanel.style.display = 'block';
if (!isMobile()) {
const specs = buildSpecs(meta);
buildDragLayer(specs, meta);
}
}
function buildSpecs(meta) {
const all = [
meta.camera ? { key: 'camera', label: 'Camera', value: meta.camera, icon: 'camera' } : null,
meta.telescope ? { key: 'telescope', label: 'Telescope', value: meta.telescope, icon: 'telescope' } : null,
meta.lensModel ? { key: 'lensModel', label: 'Lens', value: meta.lensModel, icon: 'lens' } : null,
meta.focalLength ? { key: 'focalLength', label: 'Focal Length', value: meta.focalLength, icon: 'focal' } : null,
meta.aperture ? { key: 'aperture', label: 'Aperture', value: meta.aperture, icon: 'aperture' } : null,
meta.filter ? { key: 'filter', label: 'Filter', value: meta.filter, icon: 'filter' } : null,
meta.shutter ? { key: 'shutter', label: 'Shutter/Exposure', value: meta.shutter, icon: 'shutter' } : null,
meta.frames ? { key: 'frames', label: 'Frames Stacked', value: meta.frames, icon: 'frames' } : null,
meta.totalExp ? { key: 'totalExp', label: 'Total Integration', value: meta.totalExp, icon: 'totalexp' } : null,
meta.iso ? { key: 'iso', label: 'ISO / Gain', value: meta.iso, icon: 'iso' } : null,
meta.object ? { key: 'object', label: 'Object', value: meta.object, icon: 'object' } : null,
meta.location ? { key: 'location', label: 'Location', value: meta.location, icon: 'location' } : null,
meta.bortle ? { key: 'bortle', label: 'Bortle Scale', value: meta.bortle, icon: 'bortle' } : null,
meta.date ? { key: 'date', label: 'Date', value: meta.date, icon: 'calendar' } : null,
].filter(Boolean);
if (all.length === 0) {
const hiddenCamera = editFields.camera && editFields.camera.dataset.hidden === 'true';
if (hiddenCamera) return [];
const placeholder = 'Edit text below.';
return [{ key: 'camera', label: 'Camera', value: placeholder, icon: 'camera' }];
}
return all;
}
function drawIcon(ctx, type, x, y, size) {
ctx.save();
ctx.strokeStyle = iconColor + 'b3';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const c = x + size / 2;
const m = y + size / 2;
const r = size / 2;
if (type === 'camera') {
ctx.beginPath();
ctx.roundRect(x + 2, y + 8, size - 4, size - 14, 3);
ctx.stroke();
ctx.beginPath();
ctx.arc(c, m + 2, r * 0.32, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.roundRect(x + 10, y + 4, 8, 6, 1);
ctx.stroke();
} else if (type === 'lens') {
ctx.beginPath();
ctx.arc(c, m, r * 0.72, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(c, m, r * 0.38, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(c, m, r * 0.14, 0, Math.PI * 2);
ctx.fill();
} else if (type === 'focal') {
ctx.beginPath();
ctx.moveTo(x + 4, m);
ctx.lineTo(x + size - 4, m);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + 4, m - 6);
ctx.lineTo(x + 4, m + 6);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + size - 4, m - 6);
ctx.lineTo(x + size - 4, m + 6);
ctx.stroke();
ctx.beginPath();
ctx.arc(c, m, 5, 0, Math.PI * 2);
ctx.stroke();
} else if (type === 'shutter') {
ctx.beginPath();
ctx.arc(c, m, r * 0.7, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c, m);
ctx.lineTo(c, m - r * 0.55);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c, m);
ctx.lineTo(c + r * 0.35, m - r * 0.1);
ctx.stroke();
} else if (type === 'aperture') {
ctx.beginPath();
ctx.arc(c, m, r * 0.7, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < 6; i++) {
const angle = (i * Math.PI) / 3;
ctx.beginPath();
ctx.moveTo(c + Math.cos(angle) * r * 0.3, m + Math.sin(angle) * r * 0.3);
ctx.lineTo(c + Math.cos(angle) * r * 0.65, m + Math.sin(angle) * r * 0.65);
ctx.stroke();
}
} else if (type === 'iso') {
ctx.font = `bold ${size * 0.42}px "Space Mono"`;
ctx.fillStyle = iconColor + 'b3';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('ISO', c, m);
} else if (type === 'object') {
ctx.beginPath();
ctx.arc(c, m, r * 0.68, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.ellipse(c, m, r * 0.95, r * 0.28, -0.4, 0, Math.PI * 2);
ctx.stroke();
} else if (type === 'calendar') {
ctx.beginPath();
ctx.roundRect(x + 3, y + 6, size - 6, size - 8, 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + 3, y + 14);
ctx.lineTo(x + size - 3, y + 14);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + 10, y + 3);
ctx.lineTo(x + 10, y + 10);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + size - 10, y + 3);
ctx.lineTo(x + size - 10, y + 10);
ctx.stroke();
} else if (type === 'telescope') {
ctx.beginPath();
ctx.moveTo(x + 4, m + r * 0.5);
ctx.lineTo(x + size - 4, m - r * 0.3);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + size - 4, m - r * 0.3);
ctx.lineTo(x + size - 4, m + r * 0.5);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c - 2, m + r * 0.5);
ctx.lineTo(c - 2, y + size);
ctx.stroke();
ctx.beginPath();
ctx.arc(x + size - 4, m - r * 0.3, r * 0.22, 0, Math.PI * 2);
ctx.stroke();
} else if (type === 'filter') {
ctx.beginPath();
ctx.arc(c, m, r * 0.7, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c - r * 0.4, m - r * 0.4);
ctx.lineTo(c + r * 0.4, m + r * 0.4);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c + r * 0.4, m - r * 0.4);
ctx.lineTo(c - r * 0.4, m + r * 0.4);
ctx.stroke();
} else if (type === 'frames') {
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.roundRect(x + 3 + i * 5, y + 5 + i * 4, size - 10, size - 14, 2);
ctx.stroke();
}
} else if (type === 'totalexp') {
ctx.beginPath();
ctx.arc(c, m, r * 0.7, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c, m);
ctx.lineTo(c, m - r * 0.55);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c, m);
ctx.lineTo(c + r * 0.5, m);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c - r * 0.7, m - r * 0.5);
ctx.lineTo(c - r * 0.5, m - r * 0.7);
ctx.stroke();
} else if (type === 'location') {
ctx.beginPath();
ctx.arc(c, m - r * 0.15, r * 0.5, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c - r * 0.5, m - r * 0.15);
ctx.quadraticCurveTo(c - r * 0.5, m + r * 0.7, c, m + r * 0.85);
ctx.quadraticCurveTo(c + r * 0.5, m + r * 0.7, c + r * 0.5, m - r * 0.15);
ctx.stroke();
} else if (type === 'bortle') {
for (let i = 0; i < 5; i++) {
const starX = x + 4 + i * (size - 8) / 4;
const starY = m;
ctx.beginPath();
ctx.arc(starX, starY, i < 3 ? 2.5 : 1.5, 0, Math.PI * 2);
ctx.fillStyle = iconColor + 'b3';
ctx.fill();
}
}
ctx.restore();
}
function buildDragLayer(specs, meta) {
if (isMobile()) { dragLayer.style.display = 'none'; return; }
dragLayer.innerHTML = '<canvas class="drag-grid-hint" id="grid-canvas"></canvas>';
dragItems = [];
const wrapper = document.getElementById('canvas-wrapper');
const displayW = wrapper.clientWidth;
const scale = displayW / 1080;
const SIZE = 1080;
const contentTop = 220;
const contentBottom = SIZE - 210;
const availableH = contentBottom - contentTop;
const dualMode = hasDualColumn();
const twoCol = !dualMode && specs.length > 7;
let positions = [];
if (dualMode) {
const specs2 = buildSpecs(readEditFields2());
const colCount = Math.max(specs.length, specs2.length);
const maxRowH = 90, minRowH = 48;
const rowH = Math.max(minRowH, Math.min(maxRowH, Math.floor((availableH - 60) / colCount)));
const totalBlockH = colCount * rowH;
const startY = Math.round(contentTop + 55 + (availableH - 55 - totalBlockH) / 2);
const colContentW = 420, colGap = 40;
const col1X = SIZE / 2 - (colContentW * 2 + colGap) / 2;
const col2X = col1X + colContentW + colGap;
specs.forEach((item, i) => positions.push({ item, x: col1X, y: startY + i * rowH, w: colContentW, h: rowH }));
specs2.forEach((item, i) => positions.push({ item: { ...item, key: 'col2_' + item.key }, x: col2X, y: startY + i * rowH, w: colContentW, h: rowH }));
} else if (twoCol) {
const colContentW = 380, colGap = 60;
const totalTwoColW = colContentW * 2 + colGap;
const col1X = SIZE / 2 - totalTwoColW / 2 + 60;
const col2X = col1X + colContentW + colGap;
const maxRowH = 100, minRowH = 56;
const colCount = Math.ceil(specs.length / 2);
const rowH = Math.max(minRowH, Math.min(maxRowH, Math.floor(availableH / colCount)));
const totalBlockH = colCount * rowH;
const startY = Math.round(contentTop + (availableH - totalBlockH) / 2);
const col1 = specs.slice(0, Math.ceil(specs.length / 2));
const col2 = specs.slice(Math.ceil(specs.length / 2));
col1.forEach((item, i) => positions.push({ item, x: col1X, y: startY + i * rowH, w: colContentW, h: rowH }));
col2.forEach((item, i) => positions.push({ item, x: col2X, y: startY + i * rowH, w: colContentW, h: rowH }));
} else {
const maxRowH = 100, minRowH = 62;
const rowH = Math.max(minRowH, Math.min(maxRowH, Math.floor(availableH / specs.length)));
const totalBlockH = specs.length * rowH;
const startY = Math.round(contentTop + (availableH - totalBlockH) / 2);
const blockW = 300;
const x = SIZE / 2 - blockW / 2;
specs.forEach((item, i) => positions.push({ item, x, y: startY + i * rowH, w: blockW + 100, h: rowH }));
}
const headerPos = { item: { key: 'header', label: 'Header text', value: meta.header || 'SPEC CARD', icon: null }, x: SIZE/2 - 200, y: 160, w: 400, h: 50, isHeader: true };
const footerPos = { item: { key: 'footer', label: 'Footer text', value: meta.footer || '@FateOfAstrophilia', icon: null }, x: SIZE/2 - 240, y: SIZE - 200, w: 480, h: 50, isFooter: true };
const headerBarPos = { item: { key: 'header-bar', label: 'Header line (drag vertically)', icon: null }, x: 40, y: 168, w: SIZE - 80, h: 56, isBarOnly: true };
const footerBarPos = { item: { key: 'footer-bar', label: 'Footer line (drag vertically)', icon: null }, x: 40, y: SIZE - 228, w: SIZE - 80, h: 56, isBarOnly: true };
[...[headerBarPos, footerBarPos, headerPos, footerPos], ...positions].forEach((pos, idx) => {
const custom = customDragPositions[pos.item.key];
const canvasX = custom ? custom.canvasX : pos.x;
const canvasY = custom ? custom.canvasY : pos.y;
const el = document.createElement('div');
el.className = 'drag-item';
el.dataset.key = pos.item.key;
el.style.left = (canvasX * scale) + 'px';
el.style.top = (canvasY * scale) + 'px';
el.style.width = (pos.w * scale) + 'px';
el.style.height = (pos.h * scale) + 'px';
el.style.opacity = '0';
el.style.zIndex = pos.isBarOnly ? '1' : '2';
if (pos.isBarOnly) {
el.style.borderTop = '2px solid transparent';
el.style.cursor = 'ns-resize';
el.addEventListener('mouseenter', () => { el.style.borderTop = '2px solid rgba(200,169,110,0.4)'; });
el.addEventListener('mouseleave', () => { el.style.borderTop = '2px solid transparent'; });
}
el.innerHTML = `<span class="drag-handle-icon">⠿</span>`;
el.title = 'Drag to reposition · ' + pos.item.label;
el.addEventListener('mousedown', onDragStart);
dragLayer.appendChild(el);
dragItems.push({ el, canvasX, canvasY, key: pos.item.key });
});
}
function onDragStart(e) {
if (isMobile()) return;
e.preventDefault();
const el = e.currentTarget;
const wrapper = document.getElementById('canvas-wrapper');
const wrapperRect = wrapper.getBoundingClientRect();
const displayW = wrapper.clientWidth;
const scale = displayW / 1080;
const startMouseX = e.clientX;
const startMouseY = e.clientY;
const startLeft = parseFloat(el.style.left);
const startTop = parseFloat(el.style.top);
const isBarOnly = el.dataset.key === 'header-bar' || el.dataset.key === 'footer-bar';
el.classList.add('dragging');
dragLayer.classList.add('dragging-active');
trashZone.classList.add('active');
drawGrid(displayW, wrapper.clientHeight, scale);
dragState = { el, startMouseX, startMouseY, startLeft, startTop, wrapperRect, scale, isBarOnly };
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
}
function onDragMove(e) {
if (!dragState) return;
const { el, startMouseX, startMouseY, startLeft, startTop, scale, isBarOnly } = dragState;
const dx = isBarOnly ? 0 : e.clientX - startMouseX;
const dy = e.clientY - startMouseY;
el.style.left = (startLeft + dx) + 'px';
el.style.top = (startTop + dy) + 'px';
const trashRect = trashZone.getBoundingClientRect();
const overTrash = e.clientX >= trashRect.left && e.clientX <= trashRect.right &&
e.clientY >= trashRect.top && e.clientY <= trashRect.bottom;
trashZone.classList.toggle('over', overTrash);
}
function onDragEnd(e) {
if (!dragState) return;
const { el, scale } = dragState;
const trashRect = trashZone.getBoundingClientRect();
const overTrash = e.clientX >= trashRect.left && e.clientX <= trashRect.right &&
e.clientY >= trashRect.top && e.clientY <= trashRect.bottom;
trashZone.classList.remove('active', 'over');
if (overTrash) {
const key = el.dataset.key;
if (key === 'header-bar') {
showHeader = false;
toggleHeader.checked = false;
toggleHeader.dispatchEvent(new Event('change'));
} else if (key === 'footer-bar') {
showFooter = false;
toggleFooter.checked = false;
toggleFooter.dispatchEvent(new Event('change'));
} else if (key.startsWith('col2_')) {
const col2Key = key.replace('col2_', '');
if (editFields2[col2Key]) {
editFields2[col2Key].value = '';
editFields2[col2Key].classList.remove('has-value');
}
} else if (editFields[key]) {
editFields[key].value = '';
editFields[key].classList.remove('has-value');
if (key === 'camera' && !editFields[key].value) {
editFields[key].dataset.hidden = 'true';
}
} else if (key === 'camera') {
editFields.camera.dataset.hidden = 'true';
}
delete customDragPositions[key];
el.classList.remove('dragging');
dragLayer.classList.remove('dragging-active');
clearGrid();
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
dragState = null;
if (key !== 'header-bar' && key !== 'footer-bar') {
renderCard(currentImg, readEditFields(), Object.keys(customDragPositions).length > 0);
}
return;
}
const left = parseFloat(el.style.left);
const top = parseFloat(el.style.top);
const canvasX = left / scale;
const canvasY = top / scale;
const snappedX = Math.round(canvasX / GRID) * GRID;
const snappedY = Math.round(canvasY / GRID) * GRID;
el.style.left = (snappedX * scale) + 'px';
el.style.top = (snappedY * scale) + 'px';
const item = dragItems.find(d => d.el === el);
if (item) {
item.canvasX = snappedX;
item.canvasY = snappedY;
customDragPositions[item.key] = { canvasX: snappedX, canvasY: snappedY };
}
el.classList.remove('dragging');
dragLayer.classList.remove('dragging-active');
trashZone.classList.remove('active', 'over');
clearGrid();
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
dragState = null;
reRenderWithPositions();
}
function drawGrid(displayW, displayH, scale) {
const gc = document.getElementById('grid-canvas');
if (!gc) return;
gc.width = displayW;
gc.height = displayH;
gc.style.width = displayW + 'px';
gc.style.height = displayH + 'px';
const gCtx = gc.getContext('2d');
gCtx.clearRect(0, 0, displayW, displayH);
const gridPx = GRID * scale;
gCtx.strokeStyle = 'rgba(200,169,110,0.08)';
gCtx.lineWidth = 0.5;
for (let x = 0; x < displayW; x += gridPx) {
gCtx.beginPath(); gCtx.moveTo(x, 0); gCtx.lineTo(x, displayH); gCtx.stroke();
}
for (let y = 0; y < displayH; y += gridPx) {
gCtx.beginPath(); gCtx.moveTo(0, y); gCtx.lineTo(displayW, y); gCtx.stroke();
}
}
function clearGrid() {
const gc = document.getElementById('grid-canvas');
if (gc) { const gCtx = gc.getContext('2d'); gCtx.clearRect(0, 0, gc.width, gc.height); }
}
function reRenderWithPositions() {
if (!currentImg) return;
const meta = readEditFields();
renderCard(currentImg, meta, Object.keys(customDragPositions).length > 0);
}
function renderCardWithPositions(img, meta) {
if (dragItems.length === 0) { renderCard(img, meta); return; }
renderCard(img, meta, true);
}
</script>
</body>
</html>