Files
invyone/frontend/components/dash/DashboardCanvas.tsx
T
gbpark e70267f738
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s
feat: SCADA 데모 음성 인식 + 경고 버튼 디자인 통일
- 음성 인식 (scada-demo/js/voice.js) — 한국어 발화 → 키워드 매핑 → INVYONE_UI.select()
  · 사이드바 마이크 버튼 + transcript 라벨, 매칭 시 청록 펄스
  · Chrome/Edge HTTPS 환경 (운영 siflex.invyone.com OK)
- 경고시스템/다중경고 버튼을 음성 인식과 동일 톤
  · 🚨 emoji → SVG 삼각형 아이콘, voice-btn 패턴 (다크 솔리드 + 컬러 액센트)
  · 정적 (반짝 펄스 애니메이션 제거)
- client.ts stash pop conflict 정리 (DEV_TENANT_HOST + 도메인 정리 통합)
- ui.js 다중 경고 시연 wiring + scada 작업 노트 2건
- 기타 syncthing 보류분 batch (대시보드/레이아웃/로그인 layout 정리)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:39:43 +09:00

694 lines
23 KiB
TypeScript

"use client";
import { useRef, useCallback, useEffect, useState, forwardRef, type ReactNode } from "react";
import { Plus, Save, X } from "lucide-react";
import { useDashboardStore } from "@/stores/dashboardStore";
import { useControlMode } from "@/components/control/hooks/useControlMode";
import { deleteDashboardCard } from "@/lib/api/dashMenu";
import { toast } from "sonner";
import { DashboardCard } from "./DashboardCard";
import { EmptyDashboard } from "@/components/layout/EmptyDashboard";
/**
* AnimatedFab — enter/exit 애니메이션을 가진 플로팅 액션 바.
* show=false 전환 시 closing 클래스를 주고 320ms 후 unmount.
* Ref: INVYONE Design System / ui_kits/app/dashboard-user.jsx (AnimatedFab).
*/
function AnimatedFab({ show, children }: { show: boolean; children: ReactNode }) {
const [rendered, setRendered] = useState(show);
const [closing, setClosing] = useState(false);
useEffect(() => {
if (show) {
setRendered(true);
setClosing(false);
} else if (rendered) {
setClosing(true);
const t = window.setTimeout(() => {
setRendered(false);
setClosing(false);
}, 320);
return () => window.clearTimeout(t);
}
}, [show, rendered]);
if (!rendered) return null;
return <div className={`ud-fab${closing ? "closing" : ""}`}>{children}</div>;
}
interface DashboardCanvasProps {
dashboardName: string;
onOpenLibrary: () => void;
onOpenSettings?: (cardId: string) => void;
controlMode?: boolean;
}
// ═══ 자유 배치 + 반응형을 위한 상수/유틸 ═══
// 저장 단위: % (0~100). 기존 px 데이터는 로드 시 자동 변환됨.
const SNAP_THRESHOLD = 8; // px — 스냅 걸리는 임계거리
const MIN_CARD_W_PX = 220;
const MIN_CARD_H_PX = 140;
const INTERACTION_THRESHOLD = 4;
type Dir = "" | "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
// 값이 px 로 저장된 건지 판별 — % 는 0~100, px 는 150+ 가 일반
function isValuePx(val: number): boolean {
return val > 100;
}
function pxToPct(px: number, container: number): number {
if (!container || container <= 0) return 0;
return (px / container) * 100;
}
function pctToPx(pct: number, container: number): number {
return (pct / 100) * container;
}
function round3(n: number): number {
return Math.round(n * 1000) / 1000;
}
// 카드 좌표를 항상 px 로 해석 (% 면 container 로 환산)
function readCardPx(card: Record<string, unknown>, cw: number, ch: number) {
const x = Number(card.position_x ?? card.POSITION_X ?? 0);
const y = Number(card.position_y ?? card.POSITION_Y ?? 0);
const w = Number(card.width ?? card.WIDTH ?? 0);
const h = Number(card.height ?? card.HEIGHT ?? 0);
return {
x: isValuePx(x) ? x : pctToPx(x, cw),
y: isValuePx(y) ? y : pctToPx(y, ch),
w: isValuePx(w) ? w : pctToPx(w, cw),
h: isValuePx(h) ? h : pctToPx(h, ch),
};
}
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(function DashboardCanvas(
{ dashboardName, onOpenLibrary, onOpenSettings, controlMode: controlActive },
externalRef,
) {
const cards = useDashboardStore((s) => s.cards);
const editMode = useDashboardStore((s) => s.editMode);
const setEditMode = useDashboardStore((s) => s.setEditMode);
const updateCard = useDashboardStore((s) => s.updateCard);
const removeCard = useDashboardStore((s) => s.removeCard);
const setCards = useDashboardStore((s) => s.setCards);
const activeDashboardId = useDashboardStore((s) => s.activeDashboardId);
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
const internalRef = useRef<HTMLDivElement>(null);
const canvasRef = (externalRef as React.RefObject<HTMLDivElement | null>) ?? internalRef;
const [canvasSize, setCanvasSize] = useState({ cw: 0, ch: 0 });
// 스냅 가이드 라인 (시각 피드백)
const [guides, setGuides] = useState<{ v: number[]; h: number[] }>({ v: [], h: [] });
const dragRef = useRef<{
cardId: string;
startX: number;
startY: number;
origLeft: number;
origTop: number; // px
origW: number;
origH: number; // px
liveLeft: number;
liveTop: number;
liveW: number;
liveH: number;
hasMoved: boolean;
activated: boolean;
mode: "drag" | "resize";
dir: Dir;
el: HTMLElement;
} | null>(null);
const getCanvasSize = useCallback(() => {
const cv = canvasRef.current;
const rect = cv?.getBoundingClientRect();
return {
cw: rect?.width || cv?.clientWidth || canvasSize.cw || 1000,
ch: rect?.height || cv?.clientHeight || canvasSize.ch || 600,
};
}, [canvasRef, canvasSize.ch, canvasSize.cw]);
useEffect(() => {
const cv = canvasRef.current;
if (!cv) return;
const syncSize = () => {
const rect = cv.getBoundingClientRect();
setCanvasSize({
cw: rect.width || cv.clientWidth,
ch: rect.height || cv.clientHeight,
});
};
syncSize();
const ro = new ResizeObserver(() => syncSize());
ro.observe(cv);
window.addEventListener("resize", syncSize);
return () => {
ro.disconnect();
window.removeEventListener("resize", syncSize);
};
}, [canvasRef]);
// 카드 값 정규화 — px → %, 범위 clamp (5~100, x+w≤100, y+h≤100)
// px 데이터 마이그레이션과 오버플로우/NaN 보정을 동시에 처리.
// changed=false 면 setCards 안 부르므로 무한루프 방지.
useEffect(() => {
if (cards.length === 0) return;
const { cw, ch } = canvasSize;
if (cw <= 0 || ch <= 0) return;
let changed = false;
const migrated = cards.map((c) => {
const x = Number(c.position_x ?? c.POSITION_X ?? 5);
const y = Number(c.position_y ?? c.POSITION_Y ?? 5);
const w = Number(c.width ?? c.WIDTH ?? 40);
const h = Number(c.height ?? c.HEIGHT ?? 35);
// 1) px → % (NaN 이면 기본값)
let nx = Number.isFinite(x) ? (isValuePx(x) ? pxToPct(x, cw) : x) : 5;
let ny = Number.isFinite(y) ? (isValuePx(y) ? pxToPct(y, ch) : y) : 5;
let nw = Number.isFinite(w) ? (isValuePx(w) ? pxToPct(w, cw) : w) : 40;
let nh = Number.isFinite(h) ? (isValuePx(h) ? pxToPct(h, ch) : h) : 35;
// 2) 범위 clamp — 카드가 항상 캔버스 안에 들어오도록
nw = Math.max(5, Math.min(nw, 100));
nh = Math.max(5, Math.min(nh, 100));
nx = Math.max(0, Math.min(nx, 100 - nw));
ny = Math.max(0, Math.min(ny, 100 - nh));
const changedThis =
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(w) ||
!Number.isFinite(h) ||
Math.abs(nx - x) > 0.01 ||
Math.abs(ny - y) > 0.01 ||
Math.abs(nw - w) > 0.01 ||
Math.abs(nh - h) > 0.01;
if (changedThis) {
changed = true;
return { ...c, position_x: nx, position_y: ny, width: nw, height: nh };
}
return c;
});
if (changed) setCards(migrated);
}, [cards, canvasSize, setCards]);
// 드래그 clamp + 스냅 — 리턴은 px
const applyDragConstraints = useCallback(
(cardId: string, newLeft: number, newTop: number, w: number, h: number) => {
const { cw, ch } = getCanvasSize();
// 카드가 캔버스보다 크면(마이그레이션 미동작 등 edge) 왼쪽/위 경계로 강제 이동 금지 — origin 유지
const maxL = Math.max(0, cw - w);
const maxT = Math.max(0, ch - h);
let l = w > cw ? newLeft : Math.max(0, Math.min(newLeft, maxL));
let t = h > ch ? newTop : Math.max(0, Math.min(newTop, maxT));
const vGuides: number[] = [];
const hGuides: number[] = [];
// 1) 다른 카드 가장자리 스냅
for (const o of cards) {
const oid = o.card_id ?? o.CARD_ID;
if (oid === cardId) continue;
const { x: oL, y: oT, w: oW, h: oH } = readCardPx(o, cw, ch);
const oR = oL + oW;
const oB = oT + oH;
// X: left↔otherRight, right↔otherLeft, left↔otherLeft, right↔otherRight
if (Math.abs(l - oR) < SNAP_THRESHOLD) {
l = oR;
vGuides.push(l);
} else if (Math.abs(l + w - oL) < SNAP_THRESHOLD) {
l = oL - w;
vGuides.push(oL);
} else if (Math.abs(l - oL) < SNAP_THRESHOLD) {
l = oL;
vGuides.push(l);
} else if (Math.abs(l + w - oR) < SNAP_THRESHOLD) {
l = oR - w;
vGuides.push(oR);
}
// Y
if (Math.abs(t - oB) < SNAP_THRESHOLD) {
t = oB;
hGuides.push(t);
} else if (Math.abs(t + h - oT) < SNAP_THRESHOLD) {
t = oT - h;
hGuides.push(oT);
} else if (Math.abs(t - oT) < SNAP_THRESHOLD) {
t = oT;
hGuides.push(t);
} else if (Math.abs(t + h - oB) < SNAP_THRESHOLD) {
t = oB - h;
hGuides.push(oB);
}
}
// 2) 캔버스 경계 스냅 (0 / cw / ch)
if (Math.abs(l) < SNAP_THRESHOLD) {
l = 0;
vGuides.push(0);
}
if (Math.abs(l + w - cw) < SNAP_THRESHOLD) {
l = cw - w;
vGuides.push(cw);
}
if (Math.abs(t) < SNAP_THRESHOLD) {
t = 0;
hGuides.push(0);
}
if (Math.abs(t + h - ch) < SNAP_THRESHOLD) {
t = ch - h;
hGuides.push(ch);
}
// 3) 최종 clamp — 카드가 캔버스에 맞는 경우만 경계로 clamp, 오버플로우 카드는 그대로 두기
if (w <= cw) l = Math.max(0, Math.min(l, cw - w));
if (h <= ch) t = Math.max(0, Math.min(t, ch - h));
return { l, t, vGuides, hGuides };
},
[cards, getCanvasSize],
);
// 리사이즈 clamp (방향별) — 캔버스 경계를 벗어나지 않게, 최소 크기 보장
// 오버플로우 카드 edge: 이미 카드가 캔버스보다 크면 추가 확장은 금지, 축소는 자유롭게.
const applyResizeConstraints = useCallback(
(cardId: string, origL: number, origT: number, origW: number, origH: number, dx: number, dy: number, dir: Dir) => {
const { cw, ch } = getCanvasSize();
let l = origL,
t = origT,
w = origW,
h = origH;
const vGuides: number[] = [];
const hGuides: number[] = [];
if (dir.includes("e")) {
// 확장 한계: 캔버스 오른쪽 경계. 이미 넘치고 있으면 현재 origW 를 한계로 (확장 금지, 축소 허용)
const maxW = Math.max(origW, cw - origL);
w = Math.max(MIN_CARD_W_PX, Math.min(origW + dx, maxW));
}
if (dir.includes("s")) {
const maxH = Math.max(origH, ch - origT);
h = Math.max(MIN_CARD_H_PX, Math.min(origH + dy, maxH));
}
if (dir.includes("w")) {
// w 핸들: dx 만큼 left 증가 + width 감소. 왼쪽 경계(0) 허용 범위 내에서.
const minDx = origL > 0 ? -origL : 0; // 캔버스 안에 있을 때만 왼쪽으로 확장 허용
const maxShrink = origW - MIN_CARD_W_PX;
const clampedDx = Math.max(minDx, Math.min(dx, maxShrink));
l = origL + clampedDx;
w = origW - clampedDx;
}
if (dir.includes("n")) {
const minDy = origT > 0 ? -origT : 0;
const maxShrink = origH - MIN_CARD_H_PX;
const clampedDy = Math.max(minDy, Math.min(dy, maxShrink));
t = origT + clampedDy;
h = origH - clampedDy;
}
let left = l;
let top = t;
let right = l + w;
let bottom = t + h;
const snapValue = (value: number, targets: number[]) => {
let snapped = value;
let matched = false;
for (const target of targets) {
if (Math.abs(value - target) < SNAP_THRESHOLD) {
snapped = target;
matched = true;
break;
}
}
return { snapped, matched };
};
const xTargets = [0, cw];
const yTargets = [0, ch];
for (const o of cards) {
const oid = o.card_id ?? o.CARD_ID;
if (oid === cardId) continue;
const { x: oL, y: oT, w: oW, h: oH } = readCardPx(o, cw, ch);
xTargets.push(oL, oL + oW);
yTargets.push(oT, oT + oH);
}
if (dir.includes("e")) {
const { snapped, matched } = snapValue(right, xTargets);
if (matched && snapped - left >= MIN_CARD_W_PX) {
right = snapped;
vGuides.push(snapped);
}
}
if (dir.includes("w")) {
const { snapped, matched } = snapValue(left, xTargets);
if (matched && right - snapped >= MIN_CARD_W_PX) {
left = snapped;
vGuides.push(snapped);
}
}
if (dir.includes("s")) {
const { snapped, matched } = snapValue(bottom, yTargets);
if (matched && snapped - top >= MIN_CARD_H_PX) {
bottom = snapped;
hGuides.push(snapped);
}
}
if (dir.includes("n")) {
const { snapped, matched } = snapValue(top, yTargets);
if (matched && bottom - snapped >= MIN_CARD_H_PX) {
top = snapped;
hGuides.push(snapped);
}
}
l = left;
t = top;
w = right - left;
h = bottom - top;
return { l, t, w, h, vGuides, hGuides };
},
[cards, getCanvasSize],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!editMode) return;
const target = e.target as HTMLElement;
const resizeEl = target.closest("[data-resize]") as HTMLElement | null;
const isResize = !!resizeEl;
if (!isResize) {
if (
target.closest("button") ||
target.closest("input") ||
target.closest("select") ||
target.closest("textarea")
)
return;
if (!target.closest(".dash-card")) return;
}
const wrapperEl = target.closest("[data-card-id]") as HTMLElement;
if (!wrapperEl) return;
const cardId = wrapperEl.dataset.cardId;
if (!cardId) return;
e.preventDefault();
const dir = (resizeEl?.dataset.resize ?? "") as Dir;
const canvasRect = canvasRef.current?.getBoundingClientRect();
const wrapperRect = wrapperEl.getBoundingClientRect();
const box =
canvasRect && wrapperRect.width > 0 && wrapperRect.height > 0
? {
x: wrapperRect.left - canvasRect.left,
y: wrapperRect.top - canvasRect.top,
w: wrapperRect.width,
h: wrapperRect.height,
}
: {
x: wrapperEl.offsetLeft,
y: wrapperEl.offsetTop,
w: wrapperEl.offsetWidth,
h: wrapperEl.offsetHeight,
};
dragRef.current = {
cardId,
startX: e.clientX,
startY: e.clientY,
origLeft: box.x,
origTop: box.y,
origW: box.w,
origH: box.h,
liveLeft: box.x,
liveTop: box.y,
liveW: box.w,
liveH: box.h,
hasMoved: false,
activated: false,
mode: isResize ? "resize" : "drag",
dir: isResize ? dir : "",
el: wrapperEl,
};
},
[canvasRef, editMode],
);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
if (!d.hasMoved && Math.max(Math.abs(dx), Math.abs(dy)) < INTERACTION_THRESHOLD) return;
d.hasMoved = true;
if (!d.activated) {
d.activated = true;
const cardEl = d.el.querySelector(".dash-card") as HTMLElement | null;
if (cardEl) cardEl.classList.add(d.mode === "resize" ? "resizing" : "dragging");
d.el.style.left = d.origLeft + "px";
d.el.style.top = d.origTop + "px";
d.el.style.width = d.origW + "px";
d.el.style.height = d.origH + "px";
const cursorMap: Record<string, string> = {
n: "ns-resize",
s: "ns-resize",
e: "ew-resize",
w: "ew-resize",
ne: "nesw-resize",
sw: "nesw-resize",
nw: "nwse-resize",
se: "nwse-resize",
};
document.body.style.cursor = d.mode === "resize" ? (cursorMap[d.dir] ?? "nwse-resize") : "grabbing";
document.body.style.userSelect = "none";
}
if (d.mode === "drag") {
const { l, t, vGuides, hGuides } = applyDragConstraints(
d.cardId,
d.origLeft + dx,
d.origTop + dy,
d.origW,
d.origH,
);
d.liveLeft = l;
d.liveTop = t;
d.liveW = d.origW;
d.liveH = d.origH;
d.el.style.left = l + "px";
d.el.style.top = t + "px";
setGuides({ v: vGuides, h: hGuides });
} else {
const { l, t, w, h, vGuides, hGuides } = applyResizeConstraints(
d.cardId,
d.origLeft,
d.origTop,
d.origW,
d.origH,
dx,
dy,
d.dir,
);
d.liveLeft = l;
d.liveTop = t;
d.liveW = w;
d.liveH = h;
d.el.style.left = l + "px";
d.el.style.top = t + "px";
d.el.style.width = w + "px";
d.el.style.height = h + "px";
setGuides({ v: vGuides, h: hGuides });
}
};
const handleMouseUp = () => {
const d = dragRef.current;
if (!d) return;
document.body.style.cursor = "";
document.body.style.userSelect = "";
if (!d.hasMoved) {
dragRef.current = null;
setGuides({ v: [], h: [] });
return;
}
const cardEl = d.el.querySelector(".dash-card") as HTMLElement | null;
if (cardEl) cardEl.classList.remove("dragging", "resizing");
// px → % 변환해서 store 에 저장 (다음 render 에서 % 로 다시 그려짐)
const { cw, ch } = getCanvasSize();
if (d.mode === "drag") {
const finalW = d.el.getBoundingClientRect().width || d.origW;
const finalH = d.el.getBoundingClientRect().height || d.origH;
updateCard(d.cardId, {
position_x: round3(pxToPct(d.liveLeft, cw)),
position_y: round3(pxToPct(d.liveTop, ch)),
width: round3(pxToPct(finalW, cw)),
height: round3(pxToPct(finalH, ch)),
});
} else {
updateCard(d.cardId, {
position_x: round3(pxToPct(d.liveLeft, cw)),
position_y: round3(pxToPct(d.liveTop, ch)),
width: round3(pxToPct(d.liveW, cw)),
height: round3(pxToPct(d.liveH, ch)),
});
}
dragRef.current = null;
setGuides({ v: [], h: [] });
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [applyDragConstraints, applyResizeConstraints, getCanvasSize, updateCard]);
const handleRemove = useCallback(
async (cardId: string) => {
if (!confirm("이 카드를 삭제하시겠습니까?")) return;
if (!activeDashboardId) {
toast.error("활성 대시보드가 없습니다");
return;
}
removeCard(cardId);
try {
await deleteDashboardCard(activeDashboardId, cardId);
toast.success("카드 삭제됨");
} catch (err) {
console.error("[Dashboard] 카드 삭제 실패", err);
toast.error("카드 삭제 실패 — 새로고침 후 다시 시도해주세요");
}
},
[activeDashboardId, removeCard],
);
const handleRequestSave = useCallback(() => {
window.dispatchEvent(new CustomEvent("dash:save"));
}, []);
const canvasClassName = ["dash-canvas", editMode && "edit-mode", controlActive && "control-mode"]
.filter(Boolean)
.join(" ");
return (
<div ref={canvasRef} className={canvasClassName} onMouseDown={controlActive ? undefined : handleMouseDown}>
{cards.length === 0 ? (
<EmptyDashboard />
) : (
cards.map((card) => {
const id = card.card_id ?? card.CARD_ID;
const xRaw = Number(card.position_x ?? card.POSITION_X ?? 5);
const yRaw = Number(card.position_y ?? card.POSITION_Y ?? 5);
const wRaw = Number(card.width ?? card.WIDTH ?? 40);
const hRaw = Number(card.height ?? card.HEIGHT ?? 35);
// 마이그레이션 전 px 데이터는 px 로 (곧 useEffect 가 % 로 전환), 그 외는 % 로 렌더
const asPx = isValuePx(xRaw) || isValuePx(yRaw) || isValuePx(wRaw) || isValuePx(hRaw);
const unit = asPx ? "px" : "%";
// % 렌더 시 안전망 clamp (NaN / 오버플로우 방어)
let x = xRaw,
y = yRaw,
w = wRaw,
h = hRaw;
if (!asPx) {
if (!Number.isFinite(x)) x = 5;
if (!Number.isFinite(y)) y = 5;
if (!Number.isFinite(w)) w = 40;
if (!Number.isFinite(h)) h = 35;
w = Math.max(5, Math.min(w, 100));
h = Math.max(5, Math.min(h, 100));
x = Math.max(0, Math.min(x, 100 - w));
y = Math.max(0, Math.min(y, 100 - h));
}
return (
<div
key={id}
data-card-id={id}
style={{
position: "absolute",
left: x + unit,
top: y + unit,
width: w + unit,
height: h + unit,
zIndex: 10,
}}
>
<DashboardCard
card={card}
editMode={editMode}
onRemove={handleRemove}
onOpenSettings={onOpenSettings}
/>
</div>
);
})
)}
{/* 스냅 가이드 — 드래그 중 달라붙는 지점 시각 표시 */}
{guides.v.map((x, i) => (
<div key={`gv-${i}`} className="dash-snap-guide v" style={{ left: x + "px", top: 0, bottom: 0 }} />
))}
{guides.h.map((y, i) => (
<div key={`gh-${i}`} className="dash-snap-guide h" style={{ top: y + "px", left: 0, right: 0 }} />
))}
{/* 편집 모드 FAB — 캔버스 우하단 */}
<AnimatedFab show={editMode && !controlActive}>
<span className="ud-fab-badge">
<span className="dot" />
· {cards.length}
</span>
<span className="ud-fab-sep" />
<button className="ud-fab-btn" onClick={onOpenLibrary} title="템플릿 추가">
<Plus size={12} />
<span>릿</span>
</button>
<button className="ud-fab-btn primary" onClick={handleRequestSave} title="레이아웃 저장">
<Save size={12} />
<span></span>
</button>
<button className="ud-fab-btn ghost" onClick={() => setEditMode(false)} title="편집 종료">
<X size={12} />
<span></span>
</button>
</AnimatedFab>
{/* 제어 모드 FAB */}
<AnimatedFab show={!!controlActive}>
<span className="ud-fab-badge ctrl">
<span className="dot" />
·
</span>
<span className="ud-fab-sep" />
<button className="ud-fab-btn ghost" onClick={() => toggleControlMode()} title="제어 종료">
<X size={12} />
<span></span>
</button>
</AnimatedFab>
</div>
);
});