2903 lines
97 KiB
HTML
2903 lines
97 KiB
HTML
<!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 & 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, '"')}" 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>
|