09그린 것을 JSON 으로
노드와 연결을 훑어 좌표·연결 목록이 담긴 JSON 을 만들고, 복사 단추를 누르면 줄 수가 세어져 몇 줄이 복사됐는지 알려 줍니다.
쓰임작업 내용 저장, 자동화 도구로 넘기기
JSON.stringifynavigator.clipboard.writeTextaria-live

<main class="stage">
<div class="ng is-demo" id="ng">
<div class="ng__board" id="ngCanvas">
<span class="ng__chip">그래프</span>
<svg class="ng__wires" viewBox="0 0 152 196" preserveAspectRatio="none" aria-hidden="true">
<path class="ng__wire" d="M52,51 C52,67 94,67 94,82"/>
<path class="ng__wire" d="M94,118 C94,132 52,132 52,145"/>
</svg>
<div class="ng__node ng__node--a" data-id="in">
<span class="ng__name">입력</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
</div>
<div class="ng__node ng__node--b" data-id="map">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">변환</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
</div>
<div class="ng__node ng__node--c" data-id="out">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">출력</span>
</div>
</div>
<div class="ng__panel">
<div class="ng__head">
<span class="ng__brace" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ng__ico"><path d="M240,128c-64,0,0,88-64,88H80c-64,0,0-88-64-88,64,0,0-88,64-88h96C240,40,176,128,240,128Z" opacity="0.2"/><path d="M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z"/></svg></span>
<span class="ng__file">graph.json</span>
<button type="button" class="ng__copy" id="ngCopy" aria-label="JSON 복사"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ng__ico" aria-hidden="true"><path d="M216,40V168H168V88H88V40Z" opacity="0.2"/><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg></button>
</div>
<pre class="ng__code" id="ngCode"></pre>
</div>
<p class="ng__toast" id="ngToast" aria-live="polite" data-tpl="N줄 복사했어요" data-fail="복사 실패 — 글자를 직접 선택하세요">
<span class="ng__tick" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ng__ico"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span><span class="ng__toastText" id="ngToastText">29줄 복사했어요</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 미리보기 루프 주기 — 설정 `초`(2s) 고정
$easing: $ease-spring; // JSON 한 줄이 떠오르는 곡선
$color: $subject-blue; // 연결선·복사 단추·체크 표시
@include stage($stage-ink);
.ng {
position: relative; width: min(100%, 416px);
display: grid; grid-template-columns: 38% 1fr; gap: $sp-2;
background: $subject-cream; border-radius: $r-card; padding: $sp-3; box-shadow: $shadow-raised;
}
.ng__board {
position: relative; height: clamp(140px, 44vw, 196px); overflow: hidden;
border-radius: $r-control; background-color: $stage-paper;
background-image: linear-gradient(to right, rgba($subject-ink, .10) 1px, transparent 1px),
linear-gradient(to bottom, rgba($subject-ink, .10) 1px, transparent 1px);
background-size: 24px 24px;
}
.ng__chip {
position: absolute; left: 4px; top: 4px; z-index: 2;
background: $subject-ink; color: $subject-cream; border-radius: $r-pill;
padding: 0 $sp-2; font-weight: 700; font-size: 8px; line-height: 1.6;
}
.ng__wires { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ng__wire { fill: none; stroke: $color; stroke-width: 2; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.ng__node {
position: absolute; width: 56%; height: 18%;
display: flex; align-items: center; justify-content: center;
background: $subject-cream; border: 1px solid rgba($subject-ink, .20);
border-radius: $r-sm; box-shadow: $shadow-press;
}
.ng__node--a { left: 6%; top: 8%; }
.ng__node--b { left: 34%; top: 42%; }
.ng__node--c { left: 6%; top: 74%; }
.ng__name { font-weight: 700; font-size: clamp(7px, 2vw, 9px); line-height: 1.1; color: $subject-ink; }
.ng__port {
position: absolute; left: 50%; width: 6px; height: 6px; border-radius: 50%;
background: $stage-paper; border: 2px solid $color; transform: translateX(-50%);
}
.ng__port--in { top: -4px; }
.ng__port--out { bottom: -4px; }
.ng__panel {
height: clamp(140px, 44vw, 196px); overflow: hidden;
border-radius: $r-control; background: $subject-ink; display: flex; flex-direction: column;
}
.ng__head {
display: flex; align-items: center; gap: $sp-1; padding: $sp-1 $sp-2;
border-bottom: 1px solid rgba($stage-paper, .14); flex: 0 0 auto;
}
.ng__brace { display: inline-flex; color: rgba($stage-paper, .72); flex: 0 0 auto; }
.ng__file { font-weight: 700; font-size: 8px; line-height: 1.4; color: $subject-cream; flex: 1 1 auto; }
.ng__ico { width: 12px; height: 12px; display: block; }
.ng__copy {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; padding: 0; border: none; border-radius: $r-xs;
background: $color; color: $subject-cream; cursor: pointer; flex: 0 0 auto;
transition: transform $dur-instant $ease-pop;
}
.ng__copy:active { transform: scale(.92); } // 자기 중심에서 눌린다
.ng__code {
margin: 0; padding: $sp-1 $sp-2; overflow: hidden; flex: 1 1 auto;
font-family: ui-monospace, "Consolas", "Courier New", monospace;
font-size: clamp(5px, 1.8vw, 8px); line-height: 1.3; color: $subject-cream;
white-space: pre;
}
.ng__line { display: block; }
.ng__toast {
position: absolute; right: 20px; bottom: 20px; margin: 0;
display: inline-flex; align-items: center; gap: $sp-1;
background: $subject-cream; color: $subject-ink; border-radius: $r-pill;
padding: $sp-1 $sp-3; box-shadow: $shadow-raised;
font-weight: 700; font-size: 9px; line-height: 1.2; white-space: nowrap;
opacity: 0; transform: translateY(6px);
}
.ng__tick { display: inline-flex; color: $color; flex: 0 0 auto; }
.ng__toast.is-on { opacity: 1; transform: translateY(0); }
// ── 미리보기 루프 (2s) : JSON 이 위에서 아래로 한 줄씩 떠오르고,
// 다 차면 복사 배지가 컷으로 떴다가 88% 안에 되돌아온다
.ng.is-demo .ng__line:nth-child(-n + 16) {
animation-name: ngLine; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards; // 지연 구간에 작성 상태가 한 번 번쩍이는 것을 막는다
}
@for $i from 1 through 16 {
.ng.is-demo .ng__line:nth-child(#{$i}) { animation-delay: #{($i - 1) * 22}ms; }
}
@keyframes ngLine {
0% { opacity: 0; transform: translateY(6px); }
13% { opacity: 1; transform: translateY(0); }
100% { opacity: 1; transform: translateY(0); }
}
.ng.is-demo .ng__toast {
animation-name: ngToastCut; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes ngToastCut {
0% { opacity: 0; transform: translateY(6px); }
70.5% { opacity: 1; transform: translateY(0); }
84.5% { opacity: 0; transform: translateY(6px); }
100% { opacity: 0; transform: translateY(6px); }
}
// ── 320×200 폰 (BD9). 좁은 쪽에서는 값을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.ng { padding: $sp-2; gap: $sp-1; }
.ng__head { padding: 0 $sp-1; }
.ng__code { padding: 0 $sp-1; }
.ng__file { font-size: 7px; }
.ng__copy { width: 16px; height: 16px; }
.ng__ico { width: 10px; height: 10px; }
.ng__toast { right: 12px; bottom: 12px; font-size: 8px; padding: 0 $sp-2; }
.ng__chip { font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__line,
.ng.is-demo .ng__toast { animation: none !important; }
.ng.is-demo .ng__line { opacity: 1 !important; transform: none !important; }
.ng.is-demo .ng__toast { opacity: 1 !important; transform: none !important; }
.ng__copy { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ng {
position: relative;
width: min(100%, 416px);
display: grid;
grid-template-columns: 38% 1fr;
gap: 8px;
background: #fff7e6;
border-radius: 16px;
padding: 12px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ng__board {
position: relative;
height: clamp(140px, 44vw, 196px);
overflow: hidden;
border-radius: 12px;
background-color: #ffffff;
background-image: linear-gradient(to right, rgba(23, 20, 26, 0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(23, 20, 26, 0.1) 1px, transparent 1px);
background-size: 24px 24px;
}
.ng__chip {
position: absolute;
left: 4px;
top: 4px;
z-index: 2;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
padding: 0 8px;
font-weight: 700;
font-size: 8px;
line-height: 1.6;
}
.ng__wires {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ng__wire {
fill: none;
stroke: #2f6df6;
stroke-width: 2;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.ng__node {
position: absolute;
width: 56%;
height: 18%;
display: flex;
align-items: center;
justify-content: center;
background: #fff7e6;
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ng__node--a {
left: 6%;
top: 8%;
}
.ng__node--b {
left: 34%;
top: 42%;
}
.ng__node--c {
left: 6%;
top: 74%;
}
.ng__name {
font-weight: 700;
font-size: clamp(7px, 2vw, 9px);
line-height: 1.1;
color: #17141a;
}
.ng__port {
position: absolute;
left: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: #ffffff;
border: 2px solid #2f6df6;
transform: translateX(-50%);
}
.ng__port--in {
top: -4px;
}
.ng__port--out {
bottom: -4px;
}
.ng__panel {
height: clamp(140px, 44vw, 196px);
overflow: hidden;
border-radius: 12px;
background: #17141a;
display: flex;
flex-direction: column;
}
.ng__head {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
flex: 0 0 auto;
}
.ng__brace {
display: inline-flex;
color: rgba(255, 255, 255, 0.72);
flex: 0 0 auto;
}
.ng__file {
font-weight: 700;
font-size: 8px;
line-height: 1.4;
color: #fff7e6;
flex: 1 1 auto;
}
.ng__ico {
width: 12px;
height: 12px;
display: block;
}
.ng__copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: #2f6df6;
color: #fff7e6;
cursor: pointer;
flex: 0 0 auto;
transition: transform 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ng__copy:active {
transform: scale(0.92);
}
.ng__code {
margin: 0;
padding: 4px 8px;
overflow: hidden;
flex: 1 1 auto;
font-family: ui-monospace, "Consolas", "Courier New", monospace;
font-size: clamp(5px, 1.8vw, 8px);
line-height: 1.3;
color: #fff7e6;
white-space: pre;
}
.ng__line {
display: block;
}
.ng__toast {
position: absolute;
right: 20px;
bottom: 20px;
margin: 0;
display: inline-flex;
align-items: center;
gap: 4px;
background: #fff7e6;
color: #17141a;
border-radius: 999px;
padding: 4px 12px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-weight: 700;
font-size: 9px;
line-height: 1.2;
white-space: nowrap;
opacity: 0;
transform: translateY(6px);
}
.ng__tick {
display: inline-flex;
color: #2f6df6;
flex: 0 0 auto;
}
.ng__toast.is-on {
opacity: 1;
transform: translateY(0);
}
.ng.is-demo .ng__line:nth-child(-n+16) {
animation-name: ngLine;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__line:nth-child(1) {
animation-delay: 0ms;
}
.ng.is-demo .ng__line:nth-child(2) {
animation-delay: 22ms;
}
.ng.is-demo .ng__line:nth-child(3) {
animation-delay: 44ms;
}
.ng.is-demo .ng__line:nth-child(4) {
animation-delay: 66ms;
}
.ng.is-demo .ng__line:nth-child(5) {
animation-delay: 88ms;
}
.ng.is-demo .ng__line:nth-child(6) {
animation-delay: 110ms;
}
.ng.is-demo .ng__line:nth-child(7) {
animation-delay: 132ms;
}
.ng.is-demo .ng__line:nth-child(8) {
animation-delay: 154ms;
}
.ng.is-demo .ng__line:nth-child(9) {
animation-delay: 176ms;
}
.ng.is-demo .ng__line:nth-child(10) {
animation-delay: 198ms;
}
.ng.is-demo .ng__line:nth-child(11) {
animation-delay: 220ms;
}
.ng.is-demo .ng__line:nth-child(12) {
animation-delay: 242ms;
}
.ng.is-demo .ng__line:nth-child(13) {
animation-delay: 264ms;
}
.ng.is-demo .ng__line:nth-child(14) {
animation-delay: 286ms;
}
.ng.is-demo .ng__line:nth-child(15) {
animation-delay: 308ms;
}
.ng.is-demo .ng__line:nth-child(16) {
animation-delay: 330ms;
}
@keyframes ngLine {
0% {
opacity: 0;
transform: translateY(6px);
}
13% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.ng.is-demo .ng__toast {
animation-name: ngToastCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes ngToastCut {
0% {
opacity: 0;
transform: translateY(6px);
}
70.5% {
opacity: 1;
transform: translateY(0);
}
84.5% {
opacity: 0;
transform: translateY(6px);
}
100% {
opacity: 0;
transform: translateY(6px);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ng {
padding: 8px;
gap: 4px;
}
.ng__head {
padding: 0 4px;
}
.ng__code {
padding: 0 4px;
}
.ng__file {
font-size: 7px;
}
.ng__copy {
width: 16px;
height: 16px;
}
.ng__ico {
width: 10px;
height: 10px;
}
.ng__toast {
right: 12px;
bottom: 12px;
font-size: 8px;
padding: 0 8px;
}
.ng__chip {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__line,
.ng.is-demo .ng__toast {
animation: none !important;
}
.ng.is-demo .ng__line {
opacity: 1 !important;
transform: none !important;
}
.ng.is-demo .ng__toast {
opacity: 1 !important;
transform: none !important;
}
.ng__copy {
transition: none !important;
}
}(function () {
var ng = document.getElementById('ng');
var board = document.getElementById('ngCanvas');
var code = document.getElementById('ngCode');
var copyBtn = document.getElementById('ngCopy');
var toast = document.getElementById('ngToast');
var toastText = document.getElementById('ngToastText');
var nodes = board.querySelectorAll('.ng__node');
var hideTimer = 0;
function build() {
// 화면에 놓인 노드를 그대로 훑어 객체로 만든다 — 아래 글자는 전부 이 객체에서 나온 것이다
var b = board.getBoundingClientRect();
var graph = { nodes: [], links: [] };
for (var i = 0; i < nodes.length; i++) {
var r = nodes[i].getBoundingClientRect();
graph.nodes.push({
id: nodes[i].getAttribute('data-id'),
x: Math.round(r.left - b.left),
y: Math.round(r.top - b.top)
});
}
for (var j = 1; j < graph.nodes.length; j++) {
graph.links.push({ from: graph.nodes[j - 1].id, to: graph.nodes[j].id });
}
return JSON.stringify(graph, null, 2);
}
function paint(text) {
code.textContent = '';
var rows = text.split('\n');
for (var i = 0; i < rows.length; i++) {
var row = document.createElement('span');
row.className = 'ng__line';
row.textContent = rows[i];
code.appendChild(row);
}
}
function say(text) {
toastText.textContent = text;
toast.classList.add('is-on');
clearTimeout(hideTimer);
hideTimer = setTimeout(function () { toast.classList.remove('is-on'); }, 1600);
}
var json = build();
paint(json);
copyBtn.addEventListener('click', function () {
ng.classList.remove('is-demo');
var count = json.split('\n').length; // 몇 줄인지는 글자를 직접 세어 말한다
var job = navigator.clipboard ? navigator.clipboard.writeText(json) : Promise.reject();
job.then(function () {
say(toast.getAttribute('data-tpl').replace('N', String(count)));
}, function () {
say(toast.getAttribute('data-fail'));
var range = document.createRange();
range.selectNodeContents(code);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
});
});
})();import { useEffect, useRef, useState } from "react";
import styles from "./GraphToJson.module.scss";
export type GraphToJsonProps = { duration?: number; easing?: string; color?: string };
type Box<T> = { current: T };
type GraphNode = { id: string; x: number; y: number };
type GraphLink = { from: string; to: string };
const NODES = [
{ id: "in", name: "입력", left: "6%", top: "8%", inPort: false, outPort: true },
{ id: "map", name: "변환", left: "34%", top: "42%", inPort: true, outPort: true },
{ id: "out", name: "출력", left: "6%", top: "74%", inPort: true, outPort: false },
];
const WIRES = ["M52,51 C52,67 94,67 94,82", "M94,118 C94,132 52,132 52,145"];
const FAIL = "복사 실패 — 글자를 직접 선택하세요";
const DONE = "N줄 복사했어요";
export default function GraphToJson({ duration = 2000, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: GraphToJsonProps) {
const [demo, setDemo] = useState(true);
const [json, setJson] = useState("");
const [toast, setToast] = useState("29줄 복사했어요");
const [shown, setShown] = useState(false);
const boardRef = useRef<any>(null) as Box<any>;
const codeRef = useRef<any>(null) as Box<any>;
const nodeRefs = useRef<any[]>([]) as Box<any[]>;
const rootStyle = { "--ngDur": duration + "ms", "--ngEase": easing, "--ngColor": color } as { [k: string]: string };
useEffect(() => {
// 화면에 놓인 노드를 그대로 훑어 객체로 만든다 — 아래 글자는 전부 이 객체에서 나온 것이다
const board = boardRef.current;
if (!board) return;
const b = board.getBoundingClientRect();
const nodes: GraphNode[] = [];
for (let i = 0; i < NODES.length; i++) {
const el = nodeRefs.current[i];
if (!el) continue;
const r = el.getBoundingClientRect();
nodes.push({ id: NODES[i].id, x: Math.round(r.left - b.left), y: Math.round(r.top - b.top) });
}
const links: GraphLink[] = [];
for (let j = 1; j < nodes.length; j++) links.push({ from: nodes[j - 1].id, to: nodes[j].id });
setJson(JSON.stringify({ nodes: nodes, links: links }, null, 2));
}, []);
function say(text: string) {
setToast(text);
setShown(true);
setTimeout(() => setShown(false), 1600);
}
function onCopy() {
setDemo(false);
const count = json.split("\n").length; // 몇 줄인지는 글자를 직접 세어 말한다
const job = navigator.clipboard ? navigator.clipboard.writeText(json) : Promise.reject();
job.then(
() => say(DONE.replace("N", String(count))),
() => {
say(FAIL);
const node = codeRef.current;
if (!node) return;
const range = document.createRange();
range.selectNodeContents(node);
const sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
sel.addRange(range);
}
);
}
const lines = json ? json.split("\n") : [];
return (
<main className={styles.stage}>
<div className={demo ? styles.ng + " " + styles.isDemo : styles.ng} style={rootStyle}>
<div className={styles.board} ref={(el: any) => { boardRef.current = el; }}>
<span className={styles.chip}>그래프</span>
<svg className={styles.wires} viewBox="0 0 152 196" preserveAspectRatio="none" aria-hidden="true">
{WIRES.map((d) => (
<path key={d} className={styles.wire} d={d} />
))}
</svg>
{NODES.map((n, i) => (
<div
key={n.id}
ref={(el: any) => { nodeRefs.current[i] = el; }}
className={styles.node}
style={{ left: n.left, top: n.top }}
>
{n.inPort ? <span className={styles.port + " " + styles.portIn} aria-hidden="true" /> : null}
<span className={styles.name}>{n.name}</span>
{n.outPort ? <span className={styles.port + " " + styles.portOut} aria-hidden="true" /> : null}
</div>
))}
</div>
<div className={styles.panel}>
<div className={styles.head}>
<span className={styles.brace} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><path d="M240,128c-64,0,0,88-64,88H80c-64,0,0-88-64-88,64,0,0-88,64-88h96C240,40,176,128,240,128Z" opacity="0.2"/><path d="M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z"/></svg>
</span>
<span className={styles.file}>graph.json</span>
<button type="button" className={styles.copy} aria-label="JSON 복사" onClick={onCopy}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M216,40V168H168V88H88V40Z" opacity="0.2"/><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg>
</button>
</div>
<pre className={styles.code} ref={(el: any) => { codeRef.current = el; }}>
{lines.map((row, i) => (
<span key={String(i) + row} className={styles.line}>{row}</span>
))}
</pre>
</div>
<p className={shown ? styles.toast + " " + styles.isOn : styles.toast} aria-live="polite">
<span className={styles.tick} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg>
</span>
<span className={styles.toastText}>{toast}</span>
</p>
</div>
</main>
);
}09 그린 것을 JSON 으로 — 측정 누적면적 3.749% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 플로우차트 UI 9종 — 노드를 선으로 잇는 화면
이 데모가 속한 글
플로우차트 UI 9종 — 노드를 선으로 잇는 화면플로우차트 UI 는 상자를 늘어놓고 선으로 이어 「무엇 다음에 무엇이 오는지」를 눈으로 보여 주는 화면입니다. 여기 모은 아홉 벌은 n8n·피그마 같은 편집기의 그 화면을 라이브러리 없이 바닐라 JS 와 SCSS 로 이 글의 데모 전부 보기


