---
code: "INT-06"
id: "c-flow"
tab: "dynamic-1"
kind: "interactive"
title: "Lines Effect · 흐름 트레일"
library: "ECharts"
deps: ["echarts@5"]
load: "low"
pasteKind: "echarts-mount"
family: "relationship"
dataShape: "flows, ties or connected entities"
tasks: ["explore data", "monitor change", "drill into structure", "show connections"]
tags: ["interactive", "live", "exploration", "network", "flow", "lines", "effect", "흐름", "트레일"]
gallery: "https://graph.heal7.com/#INT-06"
js: "/sku/INT-06.js"
---

# [INT-06] Lines Effect · 흐름 트레일

> AI 붙여넣기 카드. 갤러리 런타임(`main.js`)이 아니라 이 파일을 가져와라.

## 언제 가져가나
원호 경로 위로 빛 꼬리(trail)가 흐르는 다중 라인 — 흐름의 방향과 강도.

**응용** 항공/물류 노선, 자금 흐름, 데이터 파이프라인 토폴로지.

## 데이터 모양
- family: `relationship`
- dataShape: flows, ties or connected entities
- tasks: explore data, monitor change, drill into structure, show connections
- tags: interactive, live, exploration, network, flow, lines, effect, 흐름, 트레일


## 의존
echarts@5 · load `low` · library `ECharts`

## 붙여넣는 법
1. echarts 5.x 로드 (GL이면 echarts-gl도)
2. 컨테이너 id를 원본 id와 맞추거나 `document.getElementById` 한 줄만 바꾼다
3. `T`, `reg`, `cssv` 가 필요하면 갤러리 테마 스텁을 함께 가져간다

```js
const T = {
  "bg": "transparent",
  "fg": "#ece8f5",
  "sub": "#a59bbd",
  "grid": "rgba(255,255,255,.08)",
  "gold": "#e8c069",
  "royal": "#6b8cff",
  "rose": "#e07a9f",
  "teal": "#6dd2c4",
  "plum": "#b487e8"
};
const cssv = (n, fb) => (getComputedStyle(document.documentElement).getPropertyValue(n).trim() || fb);
const isDark = () => document.documentElement.getAttribute('data-theme') !== 'light';
const charts = [];
const reg = (id, opt) => {
  const dom = document.getElementById(id); if (!dom) return;
  const c = echarts.init(dom, null, {renderer:'canvas'});
  c.setOption(opt);
  charts.push(c);
  return c;
};
const factory = function(){
  const dom = document.getElementById('c-flow'); if(!dom) return;
  const c = echarts.init(dom);
  const cities = [
    {name:'서울', x:.50, y:.28, cat:'capital'},
    {name:'부산', x:.78, y:.78, cat:'port'},
    {name:'제주', x:.18, y:.92, cat:'island'},
    {name:'대전', x:.50, y:.55, cat:'inland'},
    {name:'광주', x:.32, y:.74, cat:'inland'},
    {name:'인천', x:.42, y:.30, cat:'port'},
    {name:'강릉', x:.78, y:.30, cat:'port'},
    {name:'대구', x:.66, y:.60, cat:'inland'},
    {name:'울산', x:.82, y:.62, cat:'port'},
    {name:'전주', x:.36, y:.62, cat:'inland'}
  ];
  const catLabel = {capital:'수도', port:'항구', island:'섬', inland:'내륙'};
  const catColor = {capital:T.gold, port:T.royal, island:T.teal, inland:T.rose};

  // 양방향 노선 — gold(forward) / rose(reverse). 중복 키 회피.
  const fwdLinks = [], revLinks = [];
  const seen = new Set();
  for(let tries=0; tries<60 && seen.size<24; tries++){
    const a = Math.floor(Math.random()*cities.length);
    const b = Math.floor(Math.random()*cities.length);
    if(a===b) continue;
    const key = a<b ? `${a}-${b}` : `${b}-${a}`;
    if(seen.has(key)) continue;
    seen.add(key);
    const w = .6 + Math.random()*1.4;
    const pa = [cities[a].x*100, cities[a].y*100];
    const pb = [cities[b].x*100, cities[b].y*100];
    fwdLinks.push({coords:[pa, pb], lineStyle:{width:w, curveness: .25}, value:[a,b]});
    revLinks.push({coords:[pb, pa], lineStyle:{width:w, curveness:-.25}, value:[b,a]});
  }

  // 배경 dot grid (silent — 인터랙션 무시)
  const bgDots = []; for(let i=0;i<=100;i+=10) for(let j=0;j<=100;j+=10) bgDots.push([i,j]);

  c.setOption({
    backgroundColor:T.bg,
    tooltip:{
      formatter: p => p.seriesName==='nodes'
        ? `<b>${p.data[2]}</b> · ${catLabel[p.data[3]]||'-'}`
        : ''
    },
    grid:{show:false, top:0, bottom:0, left:0, right:0},
    xAxis:{type:'value', min:0, max:100, show:false},
    yAxis:{type:'value', min:0, max:100, show:false},
    series:[
      {type:'scatter', name:'bg', coordinateSystem:'cartesian2d', silent:true,
       symbolSize:1, itemStyle:{color:'rgba(255,255,255,.05)'}, data: bgDots},
      {type:'lines', name:'forward', coordinateSystem:'cartesian2d', polyline:false,
       effect:{show:true, period:5, trailLength:.55, symbol:'arrow', symbolSize:7, color:'#fff'},
       lineStyle:{color:{type:'linear', x:0,y:0,x2:1,y2:0,
                          colorStops:[{offset:0,color:'rgba(107,140,255,.15)'},{offset:1,color:T.gold}]},
                  opacity:.65},
       emphasis:{lineStyle:{width:3, opacity:1, color:T.gold, shadowBlur:10, shadowColor:T.gold}},
       blur:{lineStyle:{opacity:.08}},
       data: fwdLinks},
      {type:'lines', name:'reverse', coordinateSystem:'cartesian2d', polyline:false,
       effect:{show:true, period:7, trailLength:.4, symbol:'circle', symbolSize:4, color:T.rose},
       lineStyle:{color:{type:'linear', x:0,y:0,x2:1,y2:0,
                          colorStops:[{offset:0,color:'rgba(224,122,159,.15)'},{offset:1,color:T.rose}]},
                  opacity:.5},
       emphasis:{lineStyle:{width:3, opacity:1, color:T.rose, shadowBlur:10, shadowColor:T.rose}},
       blur:{lineStyle:{opacity:.06}},
       data: revLinks},
      {type:'effectScatter', name:'nodes', coordinateSystem:'cartesian2d',
       rippleEffect:{scale:3.5, brushType:'stroke', period:3},
       symbolSize: (value, params) => 14 + (((params && params.data && params.data[3]) === 'capital') ? 8 : 0),
       itemStyle:{color: p=> catColor[p.data[3]] || T.gold,
                  shadowBlur:14, shadowColor:'rgba(232,192,105,.7)'},
       label:{show:true, position:'right', color:T.fg, fontSize:11, fontWeight:600, formatter:p=>p.data[2]},
       emphasis:{scale:1.4, itemStyle:{borderColor:'#fff', borderWidth:2}},
       data: cities.map(s=>[s.x*100, s.y*100, s.name, s.cat])}
    ]
  });

  // 노드 호버 → 해당 도시 인접 노선만 강조 + 다른 노선 흐리게
  c.on('mouseover', {seriesName:'nodes'}, p => {
    const idx = cities.findIndex(s => s.name === p.data[2]);
    if(idx < 0) return;
    const fwIdx = []; fwdLinks.forEach((l,i)=>{ if(l.value[0]===idx || l.value[1]===idx) fwIdx.push(i); });
    const rvIdx = []; revLinks.forEach((l,i)=>{ if(l.value[0]===idx || l.value[1]===idx) rvIdx.push(i); });
    c.dispatchAction({type:'highlight', seriesName:'forward', dataIndex:fwIdx});
    c.dispatchAction({type:'highlight', seriesName:'reverse', dataIndex:rvIdx});
  });
  c.on('mouseout', {seriesName:'nodes'}, () => {
    c.dispatchAction({type:'downplay', seriesName:'forward'});
    c.dispatchAction({type:'downplay', seriesName:'reverse'});
  });

  charts.push(c);
};
factory();
```

## 원본 factory (갤러리 정본 발췌)
- file: `frontend/js/graph-gallery/main.js`
- lines: 1070-1163
- gallery: https://graph.heal7.com/#INT-06
- raw js: https://graph.heal7.com/sku/INT-06.js

```js
function(){
  const dom = document.getElementById('c-flow'); if(!dom) return;
  const c = echarts.init(dom);
  const cities = [
    {name:'서울', x:.50, y:.28, cat:'capital'},
    {name:'부산', x:.78, y:.78, cat:'port'},
    {name:'제주', x:.18, y:.92, cat:'island'},
    {name:'대전', x:.50, y:.55, cat:'inland'},
    {name:'광주', x:.32, y:.74, cat:'inland'},
    {name:'인천', x:.42, y:.30, cat:'port'},
    {name:'강릉', x:.78, y:.30, cat:'port'},
    {name:'대구', x:.66, y:.60, cat:'inland'},
    {name:'울산', x:.82, y:.62, cat:'port'},
    {name:'전주', x:.36, y:.62, cat:'inland'}
  ];
  const catLabel = {capital:'수도', port:'항구', island:'섬', inland:'내륙'};
  const catColor = {capital:T.gold, port:T.royal, island:T.teal, inland:T.rose};

  // 양방향 노선 — gold(forward) / rose(reverse). 중복 키 회피.
  const fwdLinks = [], revLinks = [];
  const seen = new Set();
  for(let tries=0; tries<60 && seen.size<24; tries++){
    const a = Math.floor(Math.random()*cities.length);
    const b = Math.floor(Math.random()*cities.length);
    if(a===b) continue;
    const key = a<b ? `${a}-${b}` : `${b}-${a}`;
    if(seen.has(key)) continue;
    seen.add(key);
    const w = .6 + Math.random()*1.4;
    const pa = [cities[a].x*100, cities[a].y*100];
    const pb = [cities[b].x*100, cities[b].y*100];
    fwdLinks.push({coords:[pa, pb], lineStyle:{width:w, curveness: .25}, value:[a,b]});
    revLinks.push({coords:[pb, pa], lineStyle:{width:w, curveness:-.25}, value:[b,a]});
  }

  // 배경 dot grid (silent — 인터랙션 무시)
  const bgDots = []; for(let i=0;i<=100;i+=10) for(let j=0;j<=100;j+=10) bgDots.push([i,j]);

  c.setOption({
    backgroundColor:T.bg,
    tooltip:{
      formatter: p => p.seriesName==='nodes'
        ? `<b>${p.data[2]}</b> · ${catLabel[p.data[3]]||'-'}`
        : ''
    },
    grid:{show:false, top:0, bottom:0, left:0, right:0},
    xAxis:{type:'value', min:0, max:100, show:false},
    yAxis:{type:'value', min:0, max:100, show:false},
    series:[
      {type:'scatter', name:'bg', coordinateSystem:'cartesian2d', silent:true,
       symbolSize:1, itemStyle:{color:'rgba(255,255,255,.05)'}, data: bgDots},
      {type:'lines', name:'forward', coordinateSystem:'cartesian2d', polyline:false,
       effect:{show:true, period:5, trailLength:.55, symbol:'arrow', symbolSize:7, color:'#fff'},
       lineStyle:{color:{type:'linear', x:0,y:0,x2:1,y2:0,
                          colorStops:[{offset:0,color:'rgba(107,140,255,.15)'},{offset:1,color:T.gold}]},
                  opacity:.65},
       emphasis:{lineStyle:{width:3, opacity:1, color:T.gold, shadowBlur:10, shadowColor:T.gold}},
       blur:{lineStyle:{opacity:.08}},
       data: fwdLinks},
      {type:'lines', name:'reverse', coordinateSystem:'cartesian2d', polyline:false,
       effect:{show:true, period:7, trailLength:.4, symbol:'circle', symbolSize:4, color:T.rose},
       lineStyle:{color:{type:'linear', x:0,y:0,x2:1,y2:0,
                          colorStops:[{offset:0,color:'rgba(224,122,159,.15)'},{offset:1,color:T.rose}]},
                  opacity:.5},
       emphasis:{lineStyle:{width:3, opacity:1, color:T.rose, shadowBlur:10, shadowColor:T.rose}},
       blur:{lineStyle:{opacity:.06}},
       data: revLinks},
      {type:'effectScatter', name:'nodes', coordinateSystem:'cartesian2d',
       rippleEffect:{scale:3.5, brushType:'stroke', period:3},
       symbolSize: (value, params) => 14 + (((params && params.data && params.data[3]) === 'capital') ? 8 : 0),
       itemStyle:{color: p=> catColor[p.data[3]] || T.gold,
                  shadowBlur:14, shadowColor:'rgba(232,192,105,.7)'},
       label:{show:true, position:'right', color:T.fg, fontSize:11, fontWeight:600, formatter:p=>p.data[2]},
       emphasis:{scale:1.4, itemStyle:{borderColor:'#fff', borderWidth:2}},
       data: cities.map(s=>[s.x*100, s.y*100, s.name, s.cat])}
    ]
  });

  // 노드 호버 → 해당 도시 인접 노선만 강조 + 다른 노선 흐리게
  c.on('mouseover', {seriesName:'nodes'}, p => {
    const idx = cities.findIndex(s => s.name === p.data[2]);
    if(idx < 0) return;
    const fwIdx = []; fwdLinks.forEach((l,i)=>{ if(l.value[0]===idx || l.value[1]===idx) fwIdx.push(i); });
    const rvIdx = []; revLinks.forEach((l,i)=>{ if(l.value[0]===idx || l.value[1]===idx) rvIdx.push(i); });
    c.dispatchAction({type:'highlight', seriesName:'forward', dataIndex:fwIdx});
    c.dispatchAction({type:'highlight', seriesName:'reverse', dataIndex:rvIdx});
  });
  c.on('mouseout', {seriesName:'nodes'}, () => {
    c.dispatchAction({type:'downplay', seriesName:'forward'});
    c.dispatchAction({type:'downplay', seriesName:'reverse'});
  });

  charts.push(c);
}
```

