06좌표 값이 두 표기법을 오가는 동기화 인풋
도·분·초로 입력한 값을 십진수 좌표로 계산해 옆 칸에 함께 표시하고, 어느 쪽을 고쳐도 나머지 칸이 그 값에 맞춰 다시 계산된다.
쓰임지도 좌표 입력 폼·GPS 기록 도구
dmsToDecimaldecimalToDmsapplyFromDecimal

<main class="stage">
<div class="gc is-demo" id="gc">
<div class="gc__fields">
<div class="gc__field">
<span class="gc__cap">도·분·초</span>
<div class="gc__dms">
<input class="gc__deg" id="gcDeg" type="number" step="1" min="-90" max="90" value="37" aria-label="도 값 — 분·초와 함께 십진수 좌표로 계산해 눈금 위치를 옮깁니다" aria-describedby="gcReadout">
<span aria-hidden="true">°</span>
<input class="gc__min" id="gcMin" type="number" step="1" min="0" max="59" value="33" aria-label="분 값 — 도·초와 함께 십진수 좌표로 계산해 눈금 위치를 옮깁니다" aria-describedby="gcReadout">
<span aria-hidden="true">'</span>
<input class="gc__sec" id="gcSec" type="number" step="0.1" min="0" max="59.9" value="12.0" aria-label="초 값 — 도·분과 함께 십진수 좌표로 계산해 눈금 위치를 옮깁니다" aria-describedby="gcReadout">
<span aria-hidden="true">"</span>
</div>
</div>
<div class="gc__field">
<span class="gc__cap">십진수</span>
<input class="gc__dec" id="gcDec" type="number" step="0.0001" min="-90" max="90" value="37.5533" aria-label="십진수 좌표 값을 입력하면 도·분·초로 함께 계산해 눈금 위치를 옮깁니다" aria-describedby="gcReadout">
</div>
</div>
<div class="gc__scale" aria-hidden="true">
<div class="gc__track"></div>
<div class="gc__marker" id="gcMarker"></div>
</div>
<p class="gc__readout" id="gcReadout" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(도분초<->십진수 상호변환, 눈금 위치 산출)은 index.html 의 dmsToDecimal()/decimalToDms() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-yellow);
.gc {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-2;
width: min(260px, 88vw);
background: $subject-ink;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
}
.gc__fields {
display: flex;
gap: $sp-3;
width: 100%;
}
.gc__field {
display: flex;
flex-direction: column;
gap: $sp-1;
}
.gc__field:first-child {
flex: 1 1 auto;
}
.gc__field:last-child {
flex: 0 0 auto;
}
.gc__cap {
color: $color;
font-size: 11px;
font-weight: 500;
}
.gc__dms {
display: flex;
align-items: center;
gap: $sp-1;
color: $color;
font-size: 11px;
}
.gc__deg,
.gc__min,
.gc__sec {
width: 34px;
border: 0;
border-radius: $r-control;
padding: $sp-1;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 12px;
text-align: center;
}
.gc__dec {
width: 76px;
border: 0;
border-radius: $r-control;
padding: $sp-1 $sp-2;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 12px;
text-align: center;
}
.gc__deg:focus-visible,
.gc__min:focus-visible,
.gc__sec:focus-visible,
.gc__dec:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.gc__scale {
position: relative;
height: 16px;
border-radius: $r-pill;
}
.gc__track {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-cream, .16);
}
.gc__marker {
position: absolute;
top: 50%;
left: 0;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: $subject-blue;
box-shadow: $shadow-press;
transition: box-shadow $duration $easing;
will-change: transform;
}
.gc__readout {
margin: 0;
color: $color;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.gc { padding: $sp-3; gap: $sp-1; width: min(230px, 88vw); }
.gc__fields { gap: $sp-2; }
.gc__cap { font-size: 10px; }
.gc__deg, .gc__min, .gc__sec { width: 28px; font-size: 11px; }
.gc__dec { width: 64px; font-size: 11px; }
.gc__scale { height: 14px; }
.gc__marker { width: 10px; height: 10px; margin-top: -5px; }
.gc__readout { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.gc__marker { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gc {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: min(260px, 88vw);
background: #17141a;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.gc__fields {
display: flex;
gap: 12px;
width: 100%;
}
.gc__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.gc__field:first-child {
flex: 1 1 auto;
}
.gc__field:last-child {
flex: 0 0 auto;
}
.gc__cap {
color: #fff7e6;
font-size: 11px;
font-weight: 500;
}
.gc__dms {
display: flex;
align-items: center;
gap: 4px;
color: #fff7e6;
font-size: 11px;
}
.gc__deg,
.gc__min,
.gc__sec {
width: 34px;
border: 0;
border-radius: 12px;
padding: 4px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 12px;
text-align: center;
}
.gc__dec {
width: 76px;
border: 0;
border-radius: 12px;
padding: 4px 8px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 12px;
text-align: center;
}
.gc__deg:focus-visible,
.gc__min:focus-visible,
.gc__sec:focus-visible,
.gc__dec:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.gc__scale {
position: relative;
height: 16px;
border-radius: 999px;
}
.gc__track {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
}
.gc__marker {
position: absolute;
top: 50%;
left: 0;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: box-shadow 2s cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: transform;
}
.gc__readout {
margin: 0;
color: #fff7e6;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.gc {
padding: 12px;
gap: 4px;
width: min(230px, 88vw);
}
.gc__fields {
gap: 8px;
}
.gc__cap {
font-size: 10px;
}
.gc__deg, .gc__min, .gc__sec {
width: 28px;
font-size: 11px;
}
.gc__dec {
width: 64px;
font-size: 11px;
}
.gc__scale {
height: 14px;
}
.gc__marker {
width: 10px;
height: 10px;
margin-top: -5px;
}
.gc__readout {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gc__marker {
transition: none;
}
}(function () {
var root = document.getElementById('gc');
var degInput = document.getElementById('gcDeg');
var minInput = document.getElementById('gcMin');
var secInput = document.getElementById('gcSec');
var decInput = document.getElementById('gcDec');
var marker = document.getElementById('gcMarker');
var track = marker.parentElement;
var readout = document.getElementById('gcReadout');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var RANGE = 90;
// 계산 한 겹 — 도분초 <-> 십진수 상호 변환, 눈금 위 위치(0~1)
function dmsToDecimal(deg, min, sec) {
var sign = deg < 0 ? -1 : 1;
return sign * (Math.abs(deg) + min / 60 + sec / 3600);
}
function decimalToDms(dec) {
var sign = dec < 0 ? -1 : 1;
var abs = Math.abs(dec);
var deg = Math.floor(abs);
var minFull = (abs - deg) * 60;
var min = Math.floor(minFull);
var sec = (minFull - min) * 60;
return { deg: sign * deg, min: min, sec: sec };
}
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
var SENT_PREFIX = '도분초 ', SENT_MID = '는 십진수로 ', SENT_SUFFIX = '도입니다';
var targetX = 0, renderedX = 0;
var LERP_RATE = 0.15;
function paintCommon(dec) {
targetX = clamp01((dec + RANGE) / (RANGE * 2));
if (reduce) { renderedX = targetX; paint(); }
}
function readSentence(deg, min, sec, dec) {
readout.textContent = SENT_PREFIX + deg + '°' + min + "'" + sec.toFixed(1) + '"' + SENT_MID + dec.toFixed(4) + SENT_SUFFIX;
}
function applyFromDms() {
var deg = parseFloat(degInput.value); if (isNaN(deg)) deg = 0;
var min = parseFloat(minInput.value); if (isNaN(min)) min = 0;
var sec = parseFloat(secInput.value); if (isNaN(sec)) sec = 0;
var dec = dmsToDecimal(deg, min, sec);
decInput.value = dec.toFixed(4);
readSentence(deg, min, sec, dec);
paintCommon(dec);
}
function applyFromDecimal() {
var dec = parseFloat(decInput.value); if (isNaN(dec)) dec = 0;
var dms = decimalToDms(dec);
degInput.value = dms.deg;
minInput.value = dms.min;
secInput.value = dms.sec.toFixed(1);
readSentence(dms.deg, dms.min, dms.sec, dec);
paintCommon(dec);
}
function paint() {
var trackW = track.clientWidth - marker.offsetWidth;
var x = renderedX * trackW;
marker.style.transform = 'translateX(' + x.toFixed(1) + 'px)';
}
function onDmsInput() { human(); applyFromDms(); }
function onDecInput() { human(); applyFromDecimal(); }
degInput.addEventListener('input', onDmsInput);
degInput.addEventListener('change', onDmsInput);
minInput.addEventListener('input', onDmsInput);
minInput.addEventListener('change', onDmsInput);
secInput.addEventListener('input', onDmsInput);
secInput.addEventListener('change', onDmsInput);
decInput.addEventListener('input', onDecInput);
decInput.addEventListener('change', onDecInput);
var DEMO_VALUES = [12.5, 45.75, -8.333, 63.104, -51.9, 29.7801];
var DRIVE_PERIOD_MS = 2000;
var driveStart = 0, lastIdx = -1;
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) {
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
var idx = Math.floor(frac * DEMO_VALUES.length);
if (idx !== lastIdx) {
lastIdx = idx;
decInput.value = DEMO_VALUES[idx];
applyFromDecimal();
}
}
requestAnimationFrame(driveTick);
}
function frame() {
renderedX += (targetX - renderedX) * LERP_RATE;
paint();
requestAnimationFrame(frame);
}
window.addEventListener('resize', paint);
applyFromDecimal();
if (!reduce) {
requestAnimationFrame(driveTick);
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./GeoCoordSync.module.scss";
export default function GeoCoordSync() {
const rootRef = useRef<HTMLDivElement | null>(null);
const degRef = useRef<HTMLInputElement | null>(null);
const minRef = useRef<HTMLInputElement | null>(null);
const secRef = useRef<HTMLInputElement | null>(null);
const decRef = useRef<HTMLInputElement | null>(null);
const markerRef = useRef<HTMLDivElement | null>(null);
const readoutRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const degInput = degRef.current;
const minInput = minRef.current;
const secInput = secRef.current;
const decInput = decRef.current;
const marker = markerRef.current;
const readout = readoutRef.current;
if (!root || !degInput || !minInput || !secInput || !decInput || !marker || !readout) return;
const track = marker.parentElement as HTMLDivElement;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
const RANGE = 90;
function dmsToDecimal(deg: number, min: number, sec: number) {
const sign = deg < 0 ? -1 : 1;
return sign * (Math.abs(deg) + min / 60 + sec / 3600);
}
function decimalToDms(dec: number) {
const sign = dec < 0 ? -1 : 1;
const abs = Math.abs(dec);
const deg = Math.floor(abs);
const minFull = (abs - deg) * 60;
const min = Math.floor(minFull);
const sec = (minFull - min) * 60;
return { deg: sign * deg, min, sec };
}
function clamp01(v: number) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
const SENT_PREFIX = "도분초 ";
const SENT_MID = "는 십진수로 ";
const SENT_SUFFIX = "도입니다";
let targetX = 0;
let renderedX = 0;
const LERP_RATE = 0.15;
function paintCommon(dec: number) {
targetX = clamp01((dec + RANGE) / (RANGE * 2));
if (reduce) {
renderedX = targetX;
paint();
}
}
function readSentence(deg: number, min: number, sec: number, dec: number) {
readout!.textContent =
SENT_PREFIX + deg + "°" + min + "'" + sec.toFixed(1) + '"' + SENT_MID + dec.toFixed(4) + SENT_SUFFIX;
}
function applyFromDms() {
let deg = parseFloat(degInput!.value); if (isNaN(deg)) deg = 0;
let min = parseFloat(minInput!.value); if (isNaN(min)) min = 0;
let sec = parseFloat(secInput!.value); if (isNaN(sec)) sec = 0;
const dec = dmsToDecimal(deg, min, sec);
decInput!.value = dec.toFixed(4);
readSentence(deg, min, sec, dec);
paintCommon(dec);
}
function applyFromDecimal() {
let dec = parseFloat(decInput!.value); if (isNaN(dec)) dec = 0;
const dms = decimalToDms(dec);
degInput!.value = String(dms.deg);
minInput!.value = String(dms.min);
secInput!.value = dms.sec.toFixed(1);
readSentence(dms.deg, dms.min, dms.sec, dec);
paintCommon(dec);
}
function paint() {
const trackW = track.clientWidth - marker!.offsetWidth;
const x = renderedX * trackW;
marker!.style.transform = "translateX(" + x.toFixed(1) + "px)";
}
function onDmsInput() {
human();
applyFromDms();
}
function onDecInput() {
human();
applyFromDecimal();
}
degInput.addEventListener("input", onDmsInput);
degInput.addEventListener("change", onDmsInput);
minInput.addEventListener("input", onDmsInput);
minInput.addEventListener("change", onDmsInput);
secInput.addEventListener("input", onDmsInput);
secInput.addEventListener("change", onDmsInput);
decInput.addEventListener("input", onDecInput);
decInput.addEventListener("change", onDecInput);
const DEMO_VALUES = [12.5, 45.75, -8.333, 63.104, -51.9, 29.7801];
const DRIVE_PERIOD_MS = 2000;
let driveStart = 0;
let lastIdx = -1;
let rafDrive = 0;
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
const idx = Math.floor(frac * DEMO_VALUES.length);
if (idx !== lastIdx) {
lastIdx = idx;
decInput!.value = String(DEMO_VALUES[idx]);
applyFromDecimal();
}
}
rafDrive = requestAnimationFrame(driveTick);
}
let rafFrame = 0;
function frame() {
renderedX += (targetX - renderedX) * LERP_RATE;
paint();
rafFrame = requestAnimationFrame(frame);
}
window.addEventListener("resize", paint);
applyFromDecimal();
if (!reduce) {
rafDrive = requestAnimationFrame(driveTick);
rafFrame = requestAnimationFrame(frame);
}
return () => {
degInput.removeEventListener("input", onDmsInput);
degInput.removeEventListener("change", onDmsInput);
minInput.removeEventListener("input", onDmsInput);
minInput.removeEventListener("change", onDmsInput);
secInput.removeEventListener("input", onDmsInput);
secInput.removeEventListener("change", onDmsInput);
decInput.removeEventListener("input", onDecInput);
decInput.removeEventListener("change", onDecInput);
window.removeEventListener("resize", paint);
if (rafDrive) cancelAnimationFrame(rafDrive);
if (rafFrame) cancelAnimationFrame(rafFrame);
};
}, []);
return (
<div ref={rootRef} className={`${styles.root} is-demo`}>
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.cap}>도·분·초</span>
<div className={styles.dms}>
<input
ref={degRef}
className={styles.deg}
type="number"
step={1}
min={-90}
max={90}
defaultValue={37}
aria-label="도 값 — 분·초와 함께 십진수 좌표로 계산해 눈금 위치를 옮깁니다"
aria-describedby="gcReadoutReact"
/>
<span aria-hidden="true">°</span>
<input
ref={minRef}
className={styles.min}
type="number"
step={1}
min={0}
max={59}
defaultValue={33}
aria-label="분 값 — 도·초와 함께 십진수 좌표로 계산해 눈금 위치를 옮깁니다"
aria-describedby="gcReadoutReact"
/>
<span aria-hidden="true">'</span>
<input
ref={secRef}
className={styles.sec}
type="number"
step={0.1}
min={0}
max={59.9}
defaultValue={12.0}
aria-label="초 값 — 도·분과 함께 십진수 좌표로 계산해 눈금 위치를 옮깁니다"
aria-describedby="gcReadoutReact"
/>
<span aria-hidden="true">"</span>
</div>
</div>
<div className={styles.field}>
<span className={styles.cap}>십진수</span>
<input
ref={decRef}
className={styles.dec}
type="number"
step={0.0001}
min={-90}
max={90}
defaultValue={37.5533}
aria-label="십진수 좌표 값을 입력하면 도·분·초로 함께 계산해 눈금 위치를 옮깁니다"
aria-describedby="gcReadoutReact"
/>
</div>
</div>
<div className={styles.scale} aria-hidden="true">
<div className={styles.track} />
<div ref={markerRef} className={styles.marker} />
</div>
<p ref={readoutRef} className={styles.readout} id="gcReadoutReact" role="status" aria-live="polite" />
</div>
);
}06 좌표 값이 두 표기법을 오가는 동기화 인풋 — 측정 누적면적 3.172% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 계산기 디자인 9종 — 인풋 박스 디자인이 값을 그리는 방식
이 데모가 속한 글
계산기 디자인 9종 — 인풋 박스 디자인이 값을 그리는 방식css 계산기 디자인 아홉 가지 가운데 하나는 입력한 텍스트 값을 격자 값으로 바꿔 캔버스에 QR 모양을 직접 그리고, 하나는 생년월일 값으로 남은 날수를 계산해 링 게이지를 채우며, 하나는 입력한 시각 값 하나로 이 글의 데모 전부 보기
이 효과가 마음에 들면 — 비슷한 데모
입력 UI08 확인 값 실시간 대조 — 확인 입력 값과 원래 값이 갈라지는 위치 값을 실시간으로 찾는 방식비밀번호 강도 UI 9종 — 강도 값이 계산되는 아홉 가지 방식
입력 UI01 자릿수 값 그룹 전환 — 입력된 숫자 값이 네 자리마다 그 사이 간격 값이 자동으로 생기는 방식카드 번호 입력 UI 9종 — 자릿수 값마다 반응이 달라지는 아홉 가지 방식
입력 UI06 색 채움 와이프 전환 — 체크 값이 켜지면 박스의 배경 채움 값이 한쪽 모서리에서 반대쪽으로 번지듯 바뀌는 방식커스텀 체크박스 9종 — 체크 값으로 바뀌는 아홉 가지 방식