2026-06-09 13:41:27 -07:00
<!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." >
2026-06-09 15:56:43 -07:00
< meta property = "og:url" content = "https://justinoros.github.io/spec-card/" >
2026-06-09 13:41:27 -07:00
< 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 : 100 vh ;
display : flex ;
flex-direction : column ;
align-items : center ;
2026-06-09 15:56:43 -07:00
padding : 28 px 24 px 120 px ;
2026-06-09 13:41:27 -07:00
}
header {
text-align : center ;
2026-06-09 15:56:43 -07:00
margin-bottom : 28 px ;
2026-06-09 13:41:27 -07:00
}
. wordmark {
font-family : 'Space Mono' , monospace ;
font-size : 11 px ;
letter-spacing : 0.3 em ;
text-transform : uppercase ;
color : var ( -- accent );
margin-bottom : 18 px ;
opacity : 0.8 ;
}
h1 {
font-size : clamp ( 28 px , 5 vw , 46 px );
font-weight : 700 ;
letter-spacing : -0.02 em ;
line-height : 1.1 ;
color : var ( -- text );
}
h1 span {
color : var ( -- accent );
}
. subtitle {
margin-top : 14 px ;
font-size : 15 px ;
color : var ( -- muted );
font-weight : 300 ;
max-width : 360 px ;
line-height : 1.6 ;
}
. drop-zone {
width : 100 % ;
max-width : 520 px ;
border : 1.5 px dashed rgba ( 200 , 169 , 110 , 0.3 );
border-radius : 16 px ;
2026-06-09 15:56:43 -07:00
padding : 32 px 32 px ;
2026-06-09 13:41:27 -07:00
text-align : center ;
cursor : pointer ;
transition : background 0.2 s , border-color 0.2 s ;
background : var ( -- surface );
position : relative ;
}
. drop-zone : hover , . drop-zone . drag-over {
background : var ( -- drop - active );
border-color : var ( -- accent );
}
. drop-icon {
width : 52 px ;
height : 52 px ;
margin : 0 auto 20 px ;
opacity : 0.5 ;
}
. drop-zone h2 {
font-size : 17 px ;
font-weight : 500 ;
margin-bottom : 8 px ;
}
. drop-zone p {
font-size : 13 px ;
color : var ( -- muted );
margin-bottom : 24 px ;
}
. browse-btn {
display : inline-block ;
padding : 10 px 24 px ;
background : var ( -- accent - dim );
border : 1 px solid rgba ( 200 , 169 , 110 , 0.4 );
border-radius : 8 px ;
color : var ( -- accent );
font-family : 'Space Grotesk' , sans-serif ;
font-size : 13 px ;
font-weight : 600 ;
letter-spacing : 0.05 em ;
cursor : pointer ;
transition : background 0.2 s ;
}
. browse-btn : hover {
background : rgba ( 200 , 169 , 110 , 0.22 );
}
# file-input { display : none ; }
. section-divider {
width : 100 % ;
max-width : 520 px ;
display : flex ;
align-items : center ;
gap : 16 px ;
2026-06-09 15:56:43 -07:00
margin : 20 px 0 ;
2026-06-09 13:41:27 -07:00
color : var ( -- muted );
font-size : 11 px ;
letter-spacing : 0.2 em ;
text-transform : uppercase ;
font-family : 'Space Mono' , monospace ;
}
. section-divider :: before , . section-divider :: after {
content : '' ;
flex : 1 ;
height : 1 px ;
background : var ( -- border );
}
# output-section { display : none ; width : 100 % ; max-width : 560 px ; flex-direction : column ; align-items : center ; }
# canvas-wrapper {
width : 100 % ;
border-radius : 16 px ;
overflow : hidden ;
box-shadow : 0 32 px 80 px rgba ( 0 , 0 , 0 , 0.7 ), 0 0 0 1 px rgba ( 255 , 255 , 255 , 0.06 );
background : #000 ;
2026-06-10 10:08:20 -07:00
position : relative ;
}
# canvas-outer {
position : relative ;
width : 100 % ;
2026-06-09 13:41:27 -07:00
}
# preview-canvas { display : block ; width : 100 % ; height : auto ; }
2026-06-10 10:08:20 -07:00
# trash-zone {
display : none ;
position : absolute ;
bottom : -56 px ;
left : 50 % ;
transform : translateX ( -50 % );
width : 52 px ;
height : 52 px ;
border-radius : 50 % ;
background : rgba ( 220 , 60 , 60 , 0.15 );
border : 1.5 px dashed rgba ( 220 , 80 , 80 , 0.4 );
align-items : center ;
justify-content : center ;
transition : background 0.15 s , border-color 0.15 s , transform 0.15 s ;
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 : 6 px 8 px ;
border-radius : 6 px ;
transition : background 0.1 s ;
}
. drag-item : hover {
background : rgba ( 255 , 255 , 255 , 0.06 );
}
. drag-item . dragging {
cursor : grabbing ;
background : rgba ( 200 , 169 , 110 , 0.12 );
outline : 1 px dashed rgba ( 200 , 169 , 110 , 0.4 );
z-index : 100 ;
opacity : 0.85 ;
}
. drag-item . snap-indicator {
display : none ;
}
. drag-grid-hint {
position : absolute ;
inset : 0 ;
pointer-events : none ;
opacity : 0 ;
transition : opacity 0.2 s ;
}
# drag-layer . dragging-active . drag-grid-hint {
opacity : 1 ;
}
. drag-handle-icon {
position : absolute ;
top : 4 px ;
right : 4 px ;
opacity : 0 ;
font-size : 10 px ;
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 ; }
}
2026-06-09 13:41:27 -07:00
. actions {
display : flex ;
gap : 12 px ;
margin-top : 24 px ;
width : 100 % ;
flex-wrap : wrap ;
}
. btn-primary {
flex : 1 ;
min-width : 160 px ;
padding : 14 px 28 px ;
background : var ( -- accent );
color : #0a0c10 ;
border : none ;
border-radius : 10 px ;
font-family : 'Space Grotesk' , sans-serif ;
font-size : 14 px ;
font-weight : 700 ;
letter-spacing : 0.03 em ;
cursor : pointer ;
transition : opacity 0.2 s ;
}
. btn-primary : hover { opacity : 0.85 ; }
. btn-secondary {
flex : 1 ;
min-width : 160 px ;
padding : 14 px 28 px ;
background : transparent ;
color : var ( -- text );
border : 1 px solid var ( -- border );
border-radius : 10 px ;
font-family : 'Space Grotesk' , sans-serif ;
font-size : 14 px ;
font-weight : 500 ;
cursor : pointer ;
transition : border-color 0.2 s ;
}
. btn-secondary : hover { border-color : rgba ( 255 , 255 , 255 , 0.25 ); }
. ig-link {
display : inline-flex ;
align-items : center ;
gap : 7 px ;
margin-top : 20 px ;
padding : 8 px 16 px ;
background : rgba ( 200 , 169 , 110 , 0.08 );
border : 1 px solid rgba ( 200 , 169 , 110 , 0.22 );
border-radius : 999 px ;
color : var ( -- accent );
font-size : 13 px ;
font-weight : 500 ;
text-decoration : none ;
letter-spacing : 0.01 em ;
transition : background 0.2 s , border-color 0.2 s ;
}
. 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 : 14 px ;
padding : 48 px 0 ;
color : var ( -- muted );
font-size : 14 px ;
}
. spinner {
width : 32 px ;
height : 32 px ;
border : 2 px solid rgba ( 200 , 169 , 110 , 0.2 );
border-top-color : var ( -- accent );
border-radius : 50 % ;
animation : spin 0.8 s linear infinite ;
}
@ keyframes spin { to { transform : rotate ( 360 deg ); } }
. error-state {
padding : 20 px 24 px ;
background : rgba ( 220 , 80 , 80 , 0.08 );
border : 1 px solid rgba ( 220 , 80 , 80 , 0.25 );
border-radius : 10 px ;
color : #e07070 ;
font-size : 13 px ;
line-height : 1.6 ;
width : 100 % ;
}
. edit-panel {
width : 100 % ;
background : var ( -- surface );
border : 1 px solid var ( -- border );
border-radius : 12 px ;
padding : 24 px ;
margin-top : 20 px ;
}
. edit-panel h3 {
font-size : 11 px ;
font-family : 'Space Mono' , monospace ;
letter-spacing : 0.22 em ;
text-transform : uppercase ;
color : var ( -- muted );
margin-bottom : 18 px ;
}
. edit-grid {
display : grid ;
grid-template-columns : repeat ( auto - fill , minmax ( 200 px , 1 fr ));
gap : 14 px ;
}
. edit-field {
display : flex ;
flex-direction : column ;
gap : 6 px ;
}
. edit-field label {
font-size : 10 px ;
font-family : 'Space Mono' , monospace ;
letter-spacing : 0.15 em ;
text-transform : uppercase ;
color : var ( -- muted );
}
. edit-field input {
background : rgba ( 255 , 255 , 255 , 0.04 );
border : 1 px solid var ( -- border );
border-radius : 7 px ;
padding : 9 px 12 px ;
color : var ( -- text );
font-family : 'Space Grotesk' , sans-serif ;
font-size : 14 px ;
font-weight : 500 ;
transition : border-color 0.2 s ;
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 : 18 px ;
padding : 12 px ;
background : rgba ( 200 , 169 , 110 , 0.1 );
border : 1 px solid rgba ( 200 , 169 , 110 , 0.3 );
border-radius : 9 px ;
color : var ( -- accent );
font-family : 'Space Grotesk' , sans-serif ;
font-size : 13 px ;
font-weight : 600 ;
letter-spacing : 0.04 em ;
cursor : pointer ;
transition : background 0.2 s ;
}
2026-06-10 16:14:36 -07:00
. second-cam-section {
width : 100 % ;
margin-top : 24 px ;
border-top : 1 px solid var ( -- border );
padding-top : 20 px ;
}
. second-cam-toggle {
display : flex ;
align-items : center ;
justify-content : space-between ;
cursor : pointer ;
padding : 4 px 0 ;
user-select : none ;
}
. second-cam-toggle h4 {
font-size : 11 px ;
font-family : 'Space Mono' , monospace ;
letter-spacing : 0.2 em ;
text-transform : uppercase ;
color : var ( -- accent );
opacity : 0.8 ;
}
. second-cam-toggle . chevron {
color : var ( -- accent );
opacity : 0.6 ;
transition : transform 0.2 s ;
font-size : 12 px ;
}
. second-cam-toggle . open . chevron {
transform : rotate ( 180 deg );
}
. second-cam-fields {
display : none ;
margin-top : 16 px ;
}
. second-cam-fields . open {
display : block ;
}
. col-label-row {
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 14 px ;
margin-bottom : 14 px ;
}
2026-06-09 13:41:27 -07:00
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 : 10 px ;
font-family : 'Space Mono' , monospace ;
letter-spacing : 0.15 em ;
text-transform : uppercase ;
margin-bottom : 4 px ;
color : var ( -- accent );
opacity : 0.7 ;
}
. btn-regenerate : hover { background : rgba ( 200 , 169 , 110 , 0.18 ); }
2026-06-10 10:08:20 -07:00
. toggle-row {
display : flex ;
justify-content : space-between ;
align-items : center ;
width : 100 % ;
margin-top : 18 px ;
gap : 12 px ;
}
. toggle-item {
display : flex ;
align-items : center ;
gap : 10 px ;
flex : 1 ;
background : rgba ( 255 , 255 , 255 , 0.03 );
border : 1 px solid var ( -- border );
border-radius : 9 px ;
padding : 10 px 14 px ;
cursor : pointer ;
}
. toggle-item input [ type = checkbox ] { display : none ; }
. toggle-switch {
width : 36 px ;
height : 20 px ;
background : rgba ( 255 , 255 , 255 , 0.1 );
border-radius : 10 px ;
position : relative ;
flex-shrink : 0 ;
transition : background 0.2 s ;
}
. toggle-switch :: after {
content : '' ;
position : absolute ;
width : 14 px ;
height : 14 px ;
background : #fff ;
border-radius : 50 % ;
top : 3 px ;
left : 3 px ;
transition : transform 0.2 s ;
}
. toggle-item input : checked + . toggle-switch {
background : var ( -- accent );
}
. toggle-item input : checked + . toggle-switch :: after {
transform : translateX ( 16 px );
}
. toggle-label {
font-size : 12 px ;
font-family : 'Space Mono' , monospace ;
letter-spacing : 0.1 em ;
text-transform : uppercase ;
color : var ( -- muted );
}
2026-06-09 13:41:27 -07:00
. fits-notice {
font-size : 12 px ;
color : var ( -- muted );
line-height : 1.6 ;
margin-bottom : 16 px ;
padding : 10 px 14 px ;
background : rgba ( 200 , 169 , 110 , 0.06 );
border-radius : 7 px ;
border-left : 2 px solid rgba ( 200 , 169 , 110 , 0.4 );
}
. canvas-tap-hint {
text-align : center ;
font-size : 11 px ;
font-family : 'Space Mono' , monospace ;
letter-spacing : 0.15 em ;
color : rgba ( 232 , 234 , 240 , 0.28 );
margin-top : 10 px ;
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 : 24 px ;
backdrop-filter : blur ( 4 px );
}
. field-overlay-box {
background : #12151c ;
border : 1 px solid rgba ( 200 , 169 , 110 , 0.3 );
border-radius : 14 px ;
padding : 28 px 24 px ;
width : 100 % ;
max-width : 420 px ;
display : flex ;
flex-direction : column ;
gap : 16 px ;
}
. field-overlay-box h4 {
font-size : 11 px ;
font-family : 'Space Mono' , monospace ;
letter-spacing : 0.22 em ;
text-transform : uppercase ;
color : var ( -- accent );
}
. field-overlay-box input {
background : rgba ( 255 , 255 , 255 , 0.05 );
border : 1 px solid rgba ( 200 , 169 , 110 , 0.3 );
border-radius : 8 px ;
padding : 11 px 14 px ;
color : var ( -- text );
font-family : 'Space Grotesk' , sans-serif ;
font-size : 16 px ;
font-weight : 500 ;
outline : none ;
width : 100 % ;
}
. field-overlay-box input : focus {
border-color : var ( -- accent );
}
. field-overlay-btns {
display : flex ;
gap : 10 px ;
}
. field-overlay-btns button {
flex : 1 ;
padding : 11 px ;
border-radius : 8 px ;
font-family : 'Space Grotesk' , sans-serif ;
font-size : 14 px ;
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 : 6 px ;
width : 100 % ;
margin-top : 18 px ;
}
. slider-row label {
display : flex ;
justify-content : space-between ;
font-size : 10 px ;
font-family : 'Space Mono' , monospace ;
letter-spacing : 0.15 em ;
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 : 3 px ;
background : var ( -- border );
border-radius : 2 px ;
outline : none ;
cursor : pointer ;
}
. slider-row input [ type = range ] :: -webkit-slider-thumb {
-webkit- appearance : none ;
appearance : none ;
width : 16 px ;
height : 16 px ;
border-radius : 50 % ;
background : var ( -- accent );
cursor : pointer ;
border : 2 px solid #0a0c10 ;
}
. slider-row input [ type = range ] :: -moz-range-thumb {
width : 16 px ;
height : 16 px ;
border-radius : 50 % ;
background : var ( -- accent );
cursor : pointer ;
border : 2 px solid #0a0c10 ;
}
@ media ( max-width : 480px ) {
body { padding : 32 px 16 px 60 px ; }
. drop-zone { padding : 40 px 20 px ; }
}
</ 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 >
2026-06-10 10:08:20 -07:00
< 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 >
2026-06-09 13:41:27 -07:00
</ 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 >
2026-06-09 15:56:43 -07:00
< a class = "ig-link" id = "ig-link-top" href = "https://www.instagram.com/FateOfAstrophilia" target = "_blank" rel = "noopener" >
2026-06-09 15:44:59 -07:00
< 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 >
2026-06-09 13:41:27 -07:00
< div id = "output-section" >
< div class = "section-divider" > Your spec card</ div >
< div id = "status-container" ></ div >
2026-06-10 10:08:20 -07:00
< 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 >
2026-06-09 13:41:27 -07:00
</ 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 >
2026-06-09 14:32:36 -07:00
< button class = "btn-secondary" id = "upload-btn" > ⬆ Upload image</ button >
2026-06-09 14:35:36 -07:00
< input type = "file" id = "upload-any-input" accept = "*/*" style = "display:none;" >
2026-06-09 14:32:36 -07:00
< button class = "btn-secondary" id = "reset-btn" style = "display:none;" > Try another photo</ button >
2026-06-09 13:41:27 -07:00
</ 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 >
2026-06-10 10:08:20 -07:00
< input type = "text" id = "ef-header" placeholder = "e.g. @FateOfAstrophilia or SPEC CARD" value = "SPEC CARD" >
2026-06-09 13:41:27 -07:00
</ 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 >
2026-06-10 10:08:20 -07:00
< input type = "text" id = "ef-footer" placeholder = "e.g. @FateOfAstrophilia or your website" value = "@FateOfAstrophilia" >
2026-06-09 13:41:27 -07:00
</ 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 >
2026-06-10 16:14:36 -07:00
< 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 >
2026-06-09 13:41:27 -07:00
< div class = "slider-row" >
2026-06-10 10:08:20 -07:00
< 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 >
2026-06-09 13:41:27 -07:00
< 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 >
2026-06-10 10:08:20 -07:00
< 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 >
2026-06-09 13:41:27 -07:00
</ div >
2026-06-10 10:08:20 -07:00
< button class = "btn-regenerate" id = "regenerate-btn" > ↺ Update card</ button >
2026-06-09 13:41:27 -07:00
</ div >
2026-06-09 15:56:43 -07:00
< 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 >
2026-06-09 13:41:27 -07:00
< script src = "https://cdn.jsdelivr.net/npm/utif2@4.1.0/UTIF.js" ></ script >
< script >
2026-06-10 10:08:20 -07:00
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 = {};
2026-06-09 13:41:27 -07:00
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' );
2026-06-09 14:32:36 -07:00
const uploadBtn = document . getElementById ( 'upload-btn' );
const uploadAnyInput = document . getElementById ( 'upload-any-input' );
2026-06-09 13:41:27 -07:00
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' ),
};
2026-06-10 16:14:36 -07:00
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' );
});
2026-06-09 13:41:27 -07:00
const tapHint = document . getElementById ( 'tap-hint' );
2026-06-09 15:56:43 -07:00
const igLinkTop = document . getElementById ( 'ig-link-top' );
const igLinkBottom = document . getElementById ( 'ig-link-bottom' );
2026-06-09 13:41:27 -07:00
const sliderTextSize = document . getElementById ( 'slider-textsize' );
2026-06-10 10:08:20 -07:00
const sliderLabelSize = document . getElementById ( 'slider-labelsize' );
2026-06-09 13:41:27 -07:00
const sliderBlur = document . getElementById ( 'slider-blur' );
2026-06-10 10:08:20 -07:00
const toggleHeader = document . getElementById ( 'toggle-header' );
const toggleFooter = document . getElementById ( 'toggle-footer' );
2026-06-09 13:41:27 -07:00
const textsizeVal = document . getElementById ( 'textsize-val' );
2026-06-10 10:08:20 -07:00
const labelsizeVal = document . getElementById ( 'labelsize-val' );
2026-06-09 13:41:27 -07:00
const blurVal = document . getElementById ( 'blur-val' );
let currentFilename = 'spec-card.jpg' ;
let currentImg = null ;
let currentMeta = {};
let textScale = 1.0 ;
2026-06-10 10:08:20 -07:00
let labelScale = 1.4 ;
2026-06-09 13:41:27 -07:00
let blurAmount = 5 ;
2026-06-10 10:08:20 -07:00
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 );
});
});
2026-06-10 16:14:36 -07:00
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 );
});
});
2026-06-09 13:41:27 -07:00
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 ();
2026-06-10 16:14:36 -07:00
e . stopPropagation ();
2026-06-09 13:41:27 -07:00
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 = '' ;
2026-06-09 15:56:43 -07:00
igLinkTop . style . display = 'inline-flex' ;
igLinkBottom . style . display = 'none' ;
2026-06-09 13:41:27 -07:00
fileInput . value = '' ;
currentFilename = 'spec-card.jpg' ;
currentImg = null ;
currentMeta = {};
2026-06-10 10:08:20 -07:00
dragItems = [];
dragLayer . innerHTML = '' ;
customDragPositions = {};
if ( editFields . camera ) delete editFields . camera . dataset . hidden ;
2026-06-09 13:41:27 -07:00
clearStatus ();
blurAmount = 5 ;
iconColor = '#c8a96e' ;
textColor = '#e8eaf0' ;
2026-06-10 10:08:20 -07:00
showHeader = true ;
showFooter = true ;
toggleHeader . checked = true ;
toggleFooter . checked = true ;
textScale = 1.0 ;
labelScale = 1.4 ;
2026-06-09 13:41:27 -07:00
sliderTextSize . value = 100 ;
2026-06-10 10:08:20 -07:00
sliderLabelSize . value = 140 ;
sliderBlur . value = 5 ;
2026-06-09 13:41:27 -07:00
textsizeVal . textContent = '100%' ;
2026-06-10 10:08:20 -07:00
labelsizeVal . textContent = '140%' ;
2026-06-09 13:41:27 -07:00
blurVal . textContent = '5px' ;
Object . values ( editFields ). forEach ( f => { f . value = '' ; f . classList . remove ( 'has-value' ); });
2026-06-10 16:14:36 -07:00
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' );
2026-06-09 13:41:27 -07:00
});
2026-06-09 14:32:36 -07:00
uploadBtn . addEventListener ( 'click' , () => {
uploadAnyInput . value = '' ;
uploadAnyInput . click ();
});
uploadAnyInput . addEventListener ( 'change' , e => {
const file = e . target . files [ 0 ];
2026-06-09 14:58:23 -07:00
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 );
}
2026-06-09 14:32:36 -07:00
uploadAnyInput . value = '' ;
});
2026-06-09 13:41:27 -07:00
regenerateBtn . addEventListener ( 'click' , () => {
if ( ! currentImg ) return ;
const meta = readEditFields ();
2026-06-10 10:08:20 -07:00
renderCard ( currentImg , meta , Object . keys ( customDragPositions ). length > 0 );
2026-06-09 13:41:27 -07:00
});
sliderTextSize . addEventListener ( 'input' , () => {
textScale = sliderTextSize . value / 100 ;
textsizeVal . textContent = sliderTextSize . value + '%' ;
2026-06-10 10:08:20 -07:00
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 );
2026-06-09 13:41:27 -07:00
});
sliderBlur . addEventListener ( 'input' , () => {
blurAmount = parseInt ( sliderBlur . value );
blurVal . textContent = blurAmount + 'px' ;
2026-06-10 10:08:20 -07:00
if ( currentImg ) renderCard ( currentImg , readEditFields (), Object . keys ( customDragPositions ). length > 0 );
2026-06-09 13:41:27 -07:00
});
2026-06-10 10:08:20 -07:00
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 );
2026-06-09 13:41:27 -07:00
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 ;
}
2026-06-10 10:08:20 -07:00
if ( currentImg ) renderCard ( currentImg , readEditFields (), Object . keys ( customDragPositions ). length > 0 );
2026-06-09 13:41:27 -07:00
});
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 ;
}
2026-06-10 10:08:20 -07:00
if ( currentImg ) renderCard ( currentImg , readEditFields (), Object . keys ( customDragPositions ). length > 0 );
2026-06-09 13:41:27 -07:00
});
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 ());
2026-06-10 10:08:20 -07:00
const contentTop = 220 ;
const contentBottom = SIZE - 210 ;
2026-06-09 13:41:27 -07:00
const availableH = contentBottom - contentTop ;
const twoCol = specs . length > 7 ;
const regions = [
2026-06-10 10:08:20 -07:00
{ key : 'header' , label : 'Header text' , yStart : 0 , yEnd : 210 / SIZE },
2026-06-09 13:41:27 -07:00
];
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 ,
});
});
}
2026-06-10 10:08:20 -07:00
regions . push ({ key : 'footer' , label : 'Footer (IG / website)' , yStart : ( SIZE - 210 ) / SIZE , yEnd : 1 });
2026-06-09 13:41:27 -07:00
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 );
2026-06-10 10:08:20 -07:00
renderCard ( currentImg , readEditFields (), Object . keys ( customDragPositions ). length > 0 );
2026-06-09 13:41:27 -07:00
}
});
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 ();
2026-06-10 10:08:20 -07:00
renderCard ( currentImg , meta , Object . keys ( customDragPositions ). length > 0 );
2026-06-09 13:41:27 -07:00
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' ;
2026-06-09 15:56:43 -07:00
igLinkTop . style . display = 'none' ;
igLinkBottom . style . display = 'inline-flex' ;
2026-06-09 13:41:27 -07:00
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 ) {
2026-06-10 16:14:36 -07:00
const dataReader = new FileReader ();
const bufReader = new FileReader ();
dataReader . onload = e => {
2026-06-09 13:41:27 -07:00
const img = new Image ();
img . onload = () => {
2026-06-10 10:08:20 -07:00
if ( currentImg !== null ) {
2026-06-09 15:04:06 -07:00
currentImg = img ;
clearStatus ();
renderCard ( img , readEditFields ());
return ;
}
2026-06-10 10:08:20 -07:00
currentImg = img ;
clearStatus ();
2026-06-10 16:14:36 -07:00
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 ;
2026-06-10 10:08:20 -07:00
try {
EXIF . getData ( img , function () {
2026-06-10 16:14:36 -07:00
if ( exifDone ) return ;
exifDone = true ;
2026-06-10 10:08:20 -07:00
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 ) {
2026-06-10 16:14:36 -07:00
exifDone = true ;
2026-06-10 10:08:20 -07:00
renderCard ( img , readEditFields ());
}
2026-06-10 16:14:36 -07:00
2026-06-10 10:08:20 -07:00
setTimeout (() => {
2026-06-10 16:14:36 -07:00
if ( ! exifDone ) {
exifDone = true ;
2026-06-09 15:04:06 -07:00
renderCard ( img , readEditFields ());
}
2026-06-10 10:08:20 -07:00
}, 1500 );
2026-06-09 13:41:27 -07:00
};
img . onerror = () => showError ( 'Could not decode this image. Try saving it as a JPEG or PNG first.' );
img . src = e . target . result ;
};
2026-06-10 16:14:36 -07:00
bufReader . onload = e => {
bufReader . _bytes = new Uint8Array ( e . target . result );
dataReader . readAsDataURL ( file );
};
bufReader . readAsArrayBuffer ( 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 };
2026-06-09 13:41:27 -07:00
}
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 = extractMetaFromTiffIfd ( ifd );
const img = new Image ();
img . onload = () => {
currentImg = img ;
clearStatus ();
2026-06-10 10:08:20 -07:00
const cardIsShowing = currentImg !== null ;
2026-06-09 15:06:49 -07:00
if ( cardIsShowing ) {
renderCard ( img , readEditFields ());
} else {
populateEditFields ( meta , false );
renderCard ( img , meta );
}
2026-06-09 13:41:27 -07:00
};
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 );
2026-06-10 16:14:36 -07:00
const rawExif = extractExifFromBuffer ( bytes );
const meta = buildMetaFromRaw ( rawExif );
const { offset : jpegOffset , end : jpegEnd } = findEmbeddedJpeg ( bytes );
2026-06-09 13:41:27 -07:00
if ( jpegOffset === - 1 ) throw new Error ( 'No embedded preview found in this RAW file.' );
2026-06-10 16:14:36 -07:00
const jpegBytes = bytes . slice ( jpegOffset , jpegEnd );
2026-06-09 13:41:27 -07:00
const blob = new Blob ([ jpegBytes ], { type : 'image/jpeg' });
2026-06-10 16:14:36 -07:00
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.' );
});
2026-06-09 13:41:27 -07:00
} 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 cards = [];
for ( let i = 0 ; i + 80 <= text . length ; i += 80 ) {
cards . push ( text . slice ( i , i + 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' );
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' );
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' ;
2026-06-09 14:50:38 -07:00
const savedFooter = localStorage . getItem ( 'speccard_footer' );
2026-06-09 15:12:36 -07:00
const currentFooterVal = editFields . footer ? editFields . footer . value . trim () : '' ;
2026-06-10 10:08:20 -07:00
const currentHeaderVal = editFields . header ? editFields . header . value . trim () : '' ;
2026-06-09 15:12:36 -07:00
if ( ! meta . footer ) meta . footer = currentFooterVal || savedFooter || '@FateOfAstrophilia' ;
2026-06-10 10:08:20 -07:00
if ( ! meta . header ) meta . header = currentHeaderVal || 'SPEC CARD' ;
2026-06-09 13:41:27 -07:00
currentMeta = meta ;
2026-06-10 10:08:20 -07:00
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 ) + '%' ;
2026-06-09 13:41:27 -07:00
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 ;
}
2026-06-10 16:14:36 -07:00
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 () !== '' );
}
2026-06-09 13:41:27 -07:00
function findEmbeddedJpeg ( bytes ) {
2026-06-10 16:14:36 -07:00
let bestOffset = - 1 ;
let bestEnd = - 1 ;
let bestSize = 0 ;
const MAX_PREVIEW = 8 * 1024 * 1024 ;
let i = 0 ;
while ( i < bytes . length - 3 ) {
2026-06-09 13:41:27 -07:00
if ( bytes [ i ] === 0xFF && bytes [ i + 1 ] === 0xD8 && bytes [ i + 2 ] === 0xFF ) {
2026-06-10 16:14:36 -07:00
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 ;
}
2026-06-09 13:41:27 -07:00
}
}
2026-06-10 16:14:36 -07:00
i ++ ;
2026-06-09 13:41:27 -07:00
}
2026-06-10 16:14:36 -07:00
return { offset : bestOffset , end : bestEnd };
2026-06-09 13:41:27 -07:00
}
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 ;
}
2026-06-10 16:14:36 -07:00
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 ;
}
2026-06-09 13:41:27 -07:00
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 ) {
2026-06-10 16:14:36 -07:00
if ( depth > 4 || ifdOffset + 2 > bytes . length ) return ;
2026-06-09 13:41:27 -07:00
const count = readUint16 ( bytes , ifdOffset , le );
2026-06-10 16:14:36 -07:00
if ( count > 300 || count === 0 ) return ;
2026-06-09 13:41:27 -07:00
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 ;
2026-06-10 16:14:36 -07:00
if ( dataOffset + dataSize > bytes . length ) continue ;
2026-06-09 13:41:27 -07:00
if ( tag === 0x010F ) result . Make = readAscii ( bytes , dataOffset , count2 );
else if ( tag === 0x0110 ) result . Model = readAscii ( bytes , dataOffset , count2 );
2026-06-10 16:14:36 -07:00
else if ( tag === 0x9003 || tag === 0x0132 ) result . DateTimeOriginal = readAscii ( bytes , dataOffset , count2 );
2026-06-09 13:41:27 -07:00
else if ( tag === 0x829A && type === 5 ) result . ExposureTime = readRational ( bytes , dataOffset , le );
2026-06-10 16:14:36 -07:00
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 ;
}
2026-06-09 13:41:27 -07:00
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 );
2026-06-10 16:14:36 -07:00
else if ( tag === 0xA405 && type === 3 ) result . FocalLengthIn35mm = readUint16 ( bytes , dataOffset , le );
2026-06-09 13:41:27 -07:00
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 );
}
2026-06-10 16:14:36 -07:00
else if ( tag === 0xA005 && type === 4 ) {
const subOffset = base + readUint32 ( bytes , valueOffset , le );
parseIfd ( bytes , base , subOffset , le , result , depth + 1 );
}
2026-06-09 13:41:27 -07:00
}
}
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' ) || '' ;
2026-06-10 16:14:36 -07:00
result . camera = model . toLowerCase (). startsWith ( make . toLowerCase ()) ? model : [ make , model ]. filter ( Boolean ). join ( ' ' ). trim () || null ;
2026-06-09 13:41:27 -07:00
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 || '' ;
2026-06-10 16:14:36 -07:00
const camera = model . toLowerCase (). startsWith ( make . toLowerCase ()) ? model : [ make , model ]. filter ( Boolean ). join ( ' ' ). trim () || null ;
2026-06-09 13:41:27 -07:00
const lensModel = exif . LensModel || exif . Lens || null ;
let focalLength = null ;
if ( exif . FocalLength ) {
const fl = exif . FocalLength ;
2026-06-10 16:14:36 -07:00
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' ;
2026-06-09 13:41:27 -07:00
}
let aperture = null ;
if ( exif . FNumber ) {
const fn = exif . FNumber ;
2026-06-10 16:14:36 -07:00
const fnVal = typeof fn === 'object' ? ( fn . numerator / fn . denominator ) : fn ;
if ( fnVal > 0 ) aperture = 'f/' + fnVal . toFixed ( 1 );
}
if ( ! aperture && exif . ApertureValue ) {
2026-06-09 13:41:27 -07:00
const av = exif . ApertureValue ;
const raw = typeof av === 'object' ? ( av . numerator / av . denominator ) : av ;
2026-06-10 16:14:36 -07:00
if ( raw > 0 && raw < 32 ) aperture = 'f/' + Math . pow ( 2 , raw / 2 ). toFixed ( 1 );
2026-06-09 13:41:27 -07:00
}
let shutter = null ;
if ( exif . ExposureTime ) {
const et = exif . ExposureTime ;
const val = typeof et === 'object' ? ( et . numerator / et . denominator ) : et ;
2026-06-10 16:14:36 -07:00
if ( val > 0 ) shutter = val >= 1 ? val . toFixed ( 0 ) + 's' : '1/' + Math . round ( 1 / val ) + 's' ;
2026-06-09 13:41:27 -07:00
}
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 , '-' );
2026-06-10 16:14:36 -07:00
const parsed = new Date ( d );
if ( ! isNaN ( parsed )) date = parsed . toLocaleDateString ( 'en-US' , { year : 'numeric' , month : 'short' , day : 'numeric' });
2026-06-09 13:41:27 -07:00
}
}
return { camera , lensModel , focalLength , aperture , shutter , iso , date };
}
2026-06-10 10:08:20 -07:00
function renderCard ( img , meta , useCustomPositions ) {
2026-06-09 13:41:27 -07:00
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 );
2026-06-10 10:08:20 -07:00
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 ;
2026-06-09 13:41:27 -07:00
const availableH = contentBottom - contentTop ;
2026-06-10 16:14:36 -07:00
const dualMode = hasDualColumn ();
const twoCol = ! dualMode && specs . length > 7 ;
2026-06-09 13:41:27 -07:00
2026-06-10 16:14:36 -07:00
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 y = startY + i * rowH ;
drawIcon ( ctx , item . icon , colX , y + 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 (), colX + iconSize + 10 , y + Math . round ( rowH * 0.32 ));
ctx . font = `bold ${ valueSize } px "Space Grotesk", sans-serif` ;
ctx . fillStyle = textColor ;
ctx . fillText ( item . value , colX + iconSize + 10 , y + Math . round ( rowH * 0.72 ), colContentW - iconSize - 16 );
});
});
} else if ( twoCol ) {
2026-06-09 13:41:27 -07:00
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 );
2026-06-10 10:08:20 -07:00
const labelSize = Math . max ( 8 , Math . round ( 11 * scale * labelScale ));
2026-06-09 13:41:27 -07:00
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 ) => {
2026-06-10 10:08:20 -07:00
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 );
2026-06-09 13:41:27 -07:00
ctx . textAlign = 'left' ;
ctx . font = `500 ${ labelSize } px "Space Mono", monospace` ;
ctx . fillStyle = textColor + '66' ;
2026-06-10 10:08:20 -07:00
ctx . fillText ( item . label . toUpperCase (), drawX + iconSize + 12 , drawY + Math . round ( rowH * 0.3 ));
2026-06-09 13:41:27 -07:00
ctx . font = `bold ${ valueSize } px "Space Grotesk", sans-serif` ;
ctx . fillStyle = textColor ;
const maxW = colContentW - iconSize - 20 ;
2026-06-10 10:08:20 -07:00
ctx . fillText ( item . value , drawX + iconSize + 12 , drawY + Math . round ( rowH * 0.68 ), maxW );
2026-06-09 13:41:27 -07:00
});
});
} 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 );
2026-06-10 10:08:20 -07:00
const labelSize = Math . max ( 9 , Math . round ( 12 * scale * labelScale ));
2026-06-09 13:41:27 -07:00
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 ) => {
2026-06-10 10:08:20 -07:00
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 );
2026-06-09 13:41:27 -07:00
ctx . textAlign = 'left' ;
ctx . font = `500 ${ labelSize } px "Space Mono", monospace` ;
ctx . fillStyle = textColor + '66' ;
2026-06-10 10:08:20 -07:00
ctx . fillText ( item . label . toUpperCase (), drawX + iconSize + 16 , drawY + Math . round ( rowH * 0.3 ));
2026-06-09 13:41:27 -07:00
ctx . font = `bold ${ valueSize } px "Space Grotesk", sans-serif` ;
ctx . fillStyle = textColor ;
2026-06-10 10:08:20 -07:00
ctx . fillText ( item . value , drawX + iconSize + 16 , drawY + Math . round ( rowH * 0.68 ));
2026-06-09 13:41:27 -07:00
});
}
2026-06-10 10:08:20 -07:00
const footerLineY = SIZE - 200 ;
const footerTextY = SIZE - 152 ;
const footerFontSize = Math . round ( 20 * labelScale );
const footerText = ( meta . footer || '' ). trim ();
2026-06-09 13:41:27 -07:00
2026-06-10 10:08:20 -07:00
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 ();
}
2026-06-09 13:41:27 -07:00
2026-06-10 10:08:20 -07:00
if ( footerText ) {
const footerDrag = useCustomPositions && customDragPositions [ 'footer' ];
const footerDrawY = footerDrag ? footerDrag . canvasY + 36 : footerTextY ;
const footerCenterX = footerDrag ? footerDrag . canvasX + 240 : SIZE / 2 ;
2026-06-09 13:41:27 -07:00
2026-06-10 10:08:20 -07:00
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 ;
2026-06-09 13:41:27 -07:00
2026-06-10 10:08:20 -07:00
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 ();
2026-06-09 13:41:27 -07:00
2026-06-10 10:08:20 -07:00
ctx . textAlign = 'left' ;
ctx . font = `300 ${ footerFontSize } px "Space Grotesk", sans-serif` ;
ctx . fillStyle = textColor + '66' ;
ctx . fillText ( footerText , ftextX , footerDrawY );
}
2026-06-09 13:41:27 -07:00
canvasWrapper . style . display = 'block' ;
cardActions . style . display = 'flex' ;
2026-06-10 10:08:20 -07:00
tapHint . style . display = isMobile () ? 'block' : 'none' ;
2026-06-09 13:41:27 -07:00
if ( editPanel . style . display === 'none' ) editPanel . style . display = 'block' ;
2026-06-10 10:08:20 -07:00
if ( ! isMobile ()) {
const specs = buildSpecs ( meta );
buildDragLayer ( specs , meta );
}
2026-06-09 13:41:27 -07:00
}
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 ) {
2026-06-10 10:08:20 -07:00
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' }];
2026-06-09 13:41:27 -07:00
}
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 ();
}
2026-06-10 10:08:20 -07:00
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 twoCol = specs . length > 7 ;
const maxRowH = 100 , minRowH = twoCol ? 56 : 62 ;
const colCount = twoCol ? Math . ceil ( specs . length / 2 ) : specs . length ;
const rowH = Math . max ( minRowH , Math . min ( maxRowH , Math . floor ( availableH / colCount )));
const totalBlockH = colCount * rowH ;
const startY = Math . round ( contentTop + ( availableH - totalBlockH ) / 2 );
let positions = [];
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 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 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 ( 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 );
}
2026-06-09 13:41:27 -07:00
</ script >
</ body >
</ html >