09Exporting the graph as JSON
The graph is walked into JSON of coordinates and links, and the copy button reports how many lines were placed on the clipboard.
Best forSaving work, handing the graph to an automation tool
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">Graph</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">Input</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">Map</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">Output</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="Copy 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="Copied N lines" data-fail="Copy failed — select the text yourself">
<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">Copied 29 lines</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 Exporting the graph as JSON — Measured area 3.749% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · Node Based UI Design: 9 Patterns, No Library
The post this demo lives in
Node Based UI Design: 9 Patterns, No LibraryNode based ui design is the wiring screen — boxes dropped on a canvas and joined with lines, the way n8n, Figma, and Ble See all demos in this post


