feat: SCADA 데모 음성 인식 + 경고 버튼 디자인 통일
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s

- 음성 인식 (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>
This commit is contained in:
2026-05-03 05:39:43 +09:00
parent 3a0ab10ee6
commit e70267f738
21 changed files with 2320 additions and 339 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ services:
# JWT_SECRET 은 docker/dev/.env 에서 주입 (이 파일은 git 추적, .env 는 gitignored + syncthing 동기화) # JWT_SECRET 은 docker/dev/.env 에서 주입 (이 파일은 git 추적, .env 는 gitignored + syncthing 동기화)
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 환경변수 필요. docker/dev/.env 파일 확인} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 환경변수 필요. docker/dev/.env 파일 확인}
JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000} JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:9772,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:9772,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com,http://*.nip.io:[*],http://*.sslip.io:[*]}
FILE_UPLOAD_DIR: ./uploads FILE_UPLOAD_DIR: ./uploads
volumes: volumes:
- ../../backend-spring:/app - ../../backend-spring:/app
+9 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useLogin } from "@/hooks/useLogin"; import { useLogin } from "@/hooks/useLogin";
import { animatedThemeChange } from "@/lib/themeTransition"; import { animatedThemeChange } from "@/lib/themeTransition";
@@ -28,6 +28,12 @@ export default function LoginPage() {
const { theme, setTheme: setNextTheme, resolvedTheme } = useTheme(); const { theme, setTheme: setNextTheme, resolvedTheme } = useTheme();
const isDark = (resolvedTheme ?? theme) === "dark"; const isDark = (resolvedTheme ?? theme) === "dark";
// SSR 시점엔 theme 가 undefined 라 SSR HTML 과 클라 첫 렌더의 className 이 달라
// hydration mismatch 가 남. mounted 가드로 첫 렌더는 SSR 과 동일하게 그리고
// mount 후에만 isDark 반영.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// resolvedTheme 가 바뀌면 .inv-login 에 dark 클래스 동기화 (login.css 가 .inv-login.dark 셀렉터로 작성됨) // resolvedTheme 가 바뀌면 .inv-login 에 dark 클래스 동기화 (login.css 가 .inv-login.dark 셀렉터로 작성됨)
useEffect(() => { useEffect(() => {
const root = rootRef.current; const root = rootRef.current;
@@ -117,8 +123,8 @@ export default function LoginPage() {
</div> </div>
<div className="pill"> <div className="pill">
<button className={!isDark ? "on" : ""} onClick={(e) => setTheme("light", e)}>Light</button> <button className={mounted && !isDark ? "on" : ""} onClick={(e) => setTheme("light", e)}>Light</button>
<button className={isDark ? "on" : ""} onClick={(e) => setTheme("dark", e)}>Dark</button> <button className={mounted && isDark ? "on" : ""} onClick={(e) => setTheme("dark", e)}>Dark</button>
</div> </div>
<div ref={cardRef} className="login-card"> <div ref={cardRef} className="login-card">
-32
View File
@@ -1,32 +0,0 @@
'use client';
interface CardMiniViewProps {
templateName: string;
category?: string;
tableName?: string;
}
export function CardMiniView({ templateName, category, tableName }: CardMiniViewProps) {
return (
<div className="dash-mini-body">
<div className="dash-mini-stats">
<div className="dash-mini-stat">
<div className="ms-label">릿</div>
<div className="ms-value" style={{ fontSize: '.85rem' }}>{templateName}</div>
</div>
{category && (
<div className="dash-mini-stat">
<div className="ms-label"></div>
<div className="ms-value" style={{ fontSize: '.85rem' }}>{category}</div>
</div>
)}
{tableName && (
<div className="dash-mini-stat">
<div className="ms-label"></div>
<div className="ms-value" style={{ fontSize: '.75rem' }}>{tableName}</div>
</div>
)}
</div>
</div>
);
}
@@ -8,8 +8,11 @@ interface CreateDashboardModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (payload: { name: string; icon: string; is_personal: boolean }) => Promise<void> | void; onSubmit: (payload: { name: string; icon: string; is_personal: boolean }) => Promise<void> | void;
/** 'create' (기본) 는 새 대시보드 생성, 'edit' 은 기존 항목 수정 — 제목/버튼/공유범위 분기 */
mode?: 'create' | 'edit';
defaultName?: string; defaultName?: string;
defaultIcon?: string; defaultIcon?: string;
defaultIsPersonal?: boolean;
submitting?: boolean; submitting?: boolean;
} }
@@ -24,23 +27,26 @@ export function CreateDashboardModal({
open, open,
onClose, onClose,
onSubmit, onSubmit,
mode = 'create',
defaultName = '', defaultName = '',
defaultIcon = 'ClipboardList', defaultIcon = 'ClipboardList',
defaultIsPersonal = false,
submitting = false, submitting = false,
}: CreateDashboardModalProps) { }: CreateDashboardModalProps) {
const isEdit = mode === 'edit';
const [name, setName] = useState(defaultName); const [name, setName] = useState(defaultName);
const [icon, setIcon] = useState(defaultIcon); const [icon, setIcon] = useState(defaultIcon);
const [isPersonal, setIsPersonal] = useState(false); const [isPersonal, setIsPersonal] = useState(defaultIsPersonal);
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setName(defaultName); setName(defaultName);
setIcon(defaultIcon); setIcon(defaultIcon);
setIsPersonal(false); setIsPersonal(defaultIsPersonal);
setTimeout(() => nameRef.current?.focus(), 30); setTimeout(() => nameRef.current?.focus(), 30);
} }
}, [open, defaultName, defaultIcon]); }, [open, defaultName, defaultIcon, defaultIsPersonal]);
if (!open) return null; if (!open) return null;
@@ -58,7 +64,9 @@ export function CreateDashboardModal({
> >
<div className="w-[420px] max-w-[92vw] rounded-xl border border-border bg-[var(--v5-surface-solid)] p-5 shadow-[var(--v5-glow-md)]"> <div className="w-[420px] max-w-[92vw] rounded-xl border border-border bg-[var(--v5-surface-solid)] p-5 shadow-[var(--v5-glow-md)]">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-[0.95rem] font-bold text-foreground"> </h3> <h3 className="text-[0.95rem] font-bold text-foreground">
{isEdit ? '대시보드 수정' : '새 대시보드 만들기'}
</h3>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -114,14 +122,16 @@ export function CreateDashboardModal({
<div> <div>
<label className="mb-1.5 block text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground"> <label className="mb-1.5 block text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
{isEdit && <span className="ml-2 normal-case tracking-normal text-muted-foreground">( )</span>}
</label> </label>
<div className="space-y-1.5"> <div className={`space-y-1.5${isEdit ? ' opacity-60' : ''}`}>
<label className="flex cursor-pointer items-start gap-2 rounded-md border border-border p-2.5 hover:bg-accent/40"> <label className={`flex items-start gap-2 rounded-md border border-border p-2.5 ${isEdit ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-accent/40'}`}>
<input <input
type="radio" type="radio"
name="scope" name="scope"
checked={!isPersonal} checked={!isPersonal}
onChange={() => setIsPersonal(false)} onChange={() => setIsPersonal(false)}
disabled={isEdit}
className="mt-0.5 accent-[var(--v5-primary)]" className="mt-0.5 accent-[var(--v5-primary)]"
/> />
<div className="flex-1"> <div className="flex-1">
@@ -129,12 +139,13 @@ export function CreateDashboardModal({
<div className="text-xs text-muted-foreground"> ()</div> <div className="text-xs text-muted-foreground"> ()</div>
</div> </div>
</label> </label>
<label className="flex cursor-pointer items-start gap-2 rounded-md border border-border p-2.5 hover:bg-accent/40"> <label className={`flex items-start gap-2 rounded-md border border-border p-2.5 ${isEdit ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-accent/40'}`}>
<input <input
type="radio" type="radio"
name="scope" name="scope"
checked={isPersonal} checked={isPersonal}
onChange={() => setIsPersonal(true)} onChange={() => setIsPersonal(true)}
disabled={isEdit}
className="mt-0.5 accent-[var(--v5-primary)]" className="mt-0.5 accent-[var(--v5-primary)]"
/> />
<div className="flex-1"> <div className="flex-1">
@@ -159,7 +170,7 @@ export function CreateDashboardModal({
disabled={submitting || !name.trim()} disabled={submitting || !name.trim()}
className="rounded-md bg-[var(--v5-primary)] px-3 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50" className="rounded-md bg-[var(--v5-primary)] px-3 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
> >
{submitting ? '생성 중...' : '만들기'} {submitting ? (isEdit ? '저장 중...' : '생성 중...') : isEdit ? '저장' : '만들기'}
</button> </button>
</div> </div>
</form> </form>
@@ -564,16 +564,6 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
}; };
}, [applyDragConstraints, applyResizeConstraints, getCanvasSize, updateCard]); }, [applyDragConstraints, applyResizeConstraints, getCanvasSize, updateCard]);
const handleToggleCollapse = useCallback(
(cardId: string) => {
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
if (!card) return;
const wasCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
updateCard(cardId, { is_collapsed: !wasCollapsed });
},
[cards, updateCard],
);
const handleRemove = useCallback( const handleRemove = useCallback(
async (cardId: string) => { async (cardId: string) => {
if (!confirm("이 카드를 삭제하시겠습니까?")) return; if (!confirm("이 카드를 삭제하시겠습니까?")) return;
@@ -650,7 +640,6 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
card={card} card={card}
editMode={editMode} editMode={editMode}
onRemove={handleRemove} onRemove={handleRemove}
onToggleCollapse={handleToggleCollapse}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
/> />
</div> </div>
+45 -22
View File
@@ -1,12 +1,12 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { RefreshCw, ChevronDown, X, Settings } from 'lucide-react'; import { createPortal } from 'react-dom';
import { RefreshCw, X, Settings, Maximize2, Minimize2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getTemplateInfo } from '@/lib/api/template'; import { getTemplateInfo } from '@/lib/api/template';
import { fcList, fcDelete } from '@/lib/api/fcData'; import { fcList, fcDelete } from '@/lib/api/fcData';
import type { FieldConfig, Template } from '@/types/invyone-component'; import type { FieldConfig, Template } from '@/types/invyone-component';
import { CardMiniView } from './CardMiniView';
import { TemplateRenderer, type TemplateRenderContext } from './TemplateRenderer'; import { TemplateRenderer, type TemplateRenderContext } from './TemplateRenderer';
/** /**
@@ -49,7 +49,6 @@ interface DashboardCardProps {
card: Record<string, any>; card: Record<string, any>;
editMode: boolean; editMode: boolean;
onRemove: (cardId: string) => void; onRemove: (cardId: string) => void;
onToggleCollapse: (cardId: string) => void;
onOpenSettings?: (cardId: string) => void; onOpenSettings?: (cardId: string) => void;
} }
@@ -57,7 +56,6 @@ export function DashboardCard({
card, card,
editMode, editMode,
onRemove, onRemove,
onToggleCollapse,
onOpenSettings, onOpenSettings,
}: DashboardCardProps) { }: DashboardCardProps) {
const cardId = card.card_id ?? card.CARD_ID; const cardId = card.card_id ?? card.CARD_ID;
@@ -65,7 +63,36 @@ export function DashboardCard({
const templateName = card.template_name ?? card.TEMPLATE_NAME ?? '템플릿'; const templateName = card.template_name ?? card.TEMPLATE_NAME ?? '템플릿';
const templateCategory = card.template_category ?? card.TEMPLATE_CATEGORY ?? ''; const templateCategory = card.template_category ?? card.TEMPLATE_CATEGORY ?? '';
const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? ''; const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? '';
const isCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
/** 전체화면 — 일시 상태(DB 저장 X). ESC 또는 같은 버튼 재클릭으로 해제.
* closing flag 로 exit 애니메이션 후 unmount (CSS 의 dash-card-fs-out keyframe). */
const [isFullscreen, setIsFullscreen] = useState(false);
const [closing, setClosing] = useState(false);
const exitFullscreen = useCallback(() => {
setClosing(true);
window.setTimeout(() => {
setIsFullscreen(false);
setClosing(false);
}, 220);
}, []);
const toggleFullscreen = useCallback(() => {
if (isFullscreen) {
if (!closing) exitFullscreen();
} else {
setIsFullscreen(true);
}
}, [isFullscreen, closing, exitFullscreen]);
useEffect(() => {
if (!isFullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') exitFullscreen();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isFullscreen, exitFullscreen]);
const [fields, setFields] = useState<FieldConfig[]>([]); const [fields, setFields] = useState<FieldConfig[]>([]);
const [template, setTemplate] = useState<Template | null>(null); const [template, setTemplate] = useState<Template | null>(null);
@@ -313,8 +340,8 @@ export function DashboardCard({
], ],
); );
return ( const cardElement = (
<div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}> <div className={`dash-card${isFullscreen ? ' fullscreen' : ''}${closing ? ' closing' : ''}`}>
<div className="dash-card-head"> <div className="dash-card-head">
<div className="dash-card-head-l"> <div className="dash-card-head-l">
<div className="dash-card-icon">📋</div> <div className="dash-card-icon">📋</div>
@@ -346,19 +373,13 @@ export function DashboardCard({
)} )}
<button <button
className="dash-card-btn" className="dash-card-btn"
title="접기/펴기" title={isFullscreen ? '원래 크기로 (ESC)' : '전체화면'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleCollapse(cardId); toggleFullscreen();
}} }}
> >
<ChevronDown {isFullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
size={13}
style={{
transform: isCollapsed ? 'rotate(180deg)' : 'none',
transition: 'transform .25s',
}}
/>
</button> </button>
{editMode && ( {editMode && (
<button <button
@@ -393,12 +414,6 @@ export function DashboardCard({
)} )}
</div> </div>
<CardMiniView
templateName={templateName}
category={templateCategory}
tableName={primaryTable}
/>
{/* 8방향 리사이즈 핸들 — edit mode 에서만 보임 (CSS 제어) */} {/* 8방향 리사이즈 핸들 — edit mode 에서만 보임 (CSS 제어) */}
<div className="dash-resize-handle n" data-resize="n" /> <div className="dash-resize-handle n" data-resize="n" />
<div className="dash-resize-handle s" data-resize="s" /> <div className="dash-resize-handle s" data-resize="s" />
@@ -410,4 +425,12 @@ export function DashboardCard({
<div className="dash-resize-handle sw" data-resize="sw" /> <div className="dash-resize-handle sw" data-resize="sw" />
</div> </div>
); );
// 부모 .v5-body 가 overflow:hidden 으로 stacking context 를 만들어
// position:fixed 가 viewport 가 아닌 .v5-body 기준으로 갇힘.
// fullscreen 시에는 document.body 에 Portal 로 마운트해서 viewport 기준 보장.
if (isFullscreen && typeof document !== 'undefined') {
return createPortal(cardElement, document.body);
}
return cardElement;
} }
@@ -143,6 +143,9 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
// SCADA 디지털 트윈 데모 (DB 메뉴 등록은 siflex_invyone 만)
"/scada": dynamic(() => import("@/app/(main)/scada/page"), { ssr: false, loading: LoadingFallback }),
}; };
const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = { const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
+104 -33
View File
@@ -31,8 +31,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useDashboardStore } from "@/stores/dashboardStore"; import { useDashboardStore } from "@/stores/dashboardStore";
import { useControlMode } from "@/components/control/hooks/useControlMode"; import { useControlMode } from "@/components/control/hooks/useControlMode";
import { insertDashboard } from "@/lib/api/dashMenu"; import { insertDashboard, getDashboardList, updateDashboard } from "@/lib/api/dashMenu";
import { CreateDashboardModal } from "@/components/dash/CreateDashboardModal"; import { CreateDashboardModal } from "@/components/dash/CreateDashboardModal";
import { MenuItemActions } from "@/components/layout/MenuItemActions";
import { useMenu } from "@/contexts/MenuContext"; import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile"; import { useProfile } from "@/hooks/useProfile";
@@ -282,6 +283,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false); const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>(""); const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const tweaksAnchorRef = useRef<HTMLButtonElement>(null);
const { theme, setTheme: rawSetTheme } = useTheme(); const { theme, setTheme: rawSetTheme } = useTheme();
// 메뉴 방향 (vertical: 사이드바 / horizontal: 헤더 내 TopNav). localStorage 유지. // 메뉴 방향 (vertical: 사이드바 / horizontal: 헤더 내 TopNav). localStorage 유지.
@@ -307,6 +309,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const dashCreateOpen = useDashboardStore((s) => s.createOpen); const dashCreateOpen = useDashboardStore((s) => s.createOpen);
const openDashCreate = useDashboardStore((s) => s.openCreate); const openDashCreate = useDashboardStore((s) => s.openCreate);
const closeDashCreate = useDashboardStore((s) => s.closeCreate); const closeDashCreate = useDashboardStore((s) => s.closeCreate);
const setDashboardsList = useDashboardStore((s) => s.setDashboards);
const dashEditTarget = useDashboardStore((s) => s.editTarget);
const closeDashEdit = useDashboardStore((s) => s.closeEdit);
const dashEditMode = useDashboardStore((s) => s.editMode); const dashEditMode = useDashboardStore((s) => s.editMode);
const toggleDashEditMode = useDashboardStore((s) => s.toggleEditMode); const toggleDashEditMode = useDashboardStore((s) => s.toggleEditMode);
const setDashEditMode = useDashboardStore((s) => s.setEditMode); const setDashEditMode = useDashboardStore((s) => s.setEditMode);
@@ -320,6 +325,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
try { try {
const result = await insertDashboard(payload); const result = await insertDashboard(payload);
try { await refreshMenus(); } catch { /* ignore */ } try { await refreshMenus(); } catch { /* ignore */ }
try {
const list = await getDashboardList();
setDashboardsList(list?.list ?? []);
} catch { /* ignore */ }
const newUrl = result?.menu_url ?? result?.MENU_URL; const newUrl = result?.menu_url ?? result?.MENU_URL;
closeDashCreate(); closeDashCreate();
toast.success(`"${payload.name}" 대시보드를 만들었습니다`); toast.success(`"${payload.name}" 대시보드를 만들었습니다`);
@@ -329,7 +338,54 @@ function AppLayoutInner({ children }: AppLayoutProps) {
} finally { } finally {
setDashCreateSubmitting(false); setDashCreateSubmitting(false);
} }
}, [refreshMenus, closeDashCreate, router]); }, [refreshMenus, closeDashCreate, router, setDashboardsList]);
// 등록/수정 통합 — editTarget 이 있으면 수정, 없으면 신규 생성.
// 공유 범위(is_personal) 는 backend updateDashboard 에서 무시됨 (모달도 disabled)
const handleDashModalSubmit = useCallback(async (payload: { name: string; icon: string; is_personal: boolean }) => {
if (dashEditTarget) {
setDashCreateSubmitting(true);
try {
await updateDashboard(dashEditTarget.id, { name: payload.name, icon: payload.icon });
try { await refreshMenus(); } catch { /* ignore */ }
try {
const list = await getDashboardList();
setDashboardsList(list?.list ?? []);
} catch { /* ignore */ }
closeDashEdit();
toast.success("대시보드가 수정되었습니다");
} catch (err) {
console.error("[AppLayout] 대시보드 수정 실패", err);
toast.error("수정 실패");
} finally {
setDashCreateSubmitting(false);
}
} else {
await handleDashCreateSubmit(payload);
}
}, [dashEditTarget, refreshMenus, setDashboardsList, closeDashEdit, handleDashCreateSubmit]);
const handleDashModalClose = useCallback(() => {
closeDashCreate();
closeDashEdit();
}, [closeDashCreate, closeDashEdit]);
// 사이드바 메뉴(=대시보드) 의 ⋮ 액션이 dashboards store 에서 menu_url 매칭으로
// objid 를 찾기 때문에 AppLayout 마운트 시 한 번 채워둔다. DashboardLayout 페이지가
// 같은 fetch 를 또 하지만 zustand 가 덮어쓰니 무해.
useEffect(() => {
if (!user) return;
let cancelled = false;
(async () => {
try {
const result = await getDashboardList();
if (!cancelled) setDashboardsList(result?.list ?? []);
} catch (err) {
console.warn("[AppLayout] dashboard list 로드 실패", err);
}
})();
return () => { cancelled = true; };
}, [user, setDashboardsList]);
// 테마 전환 — 클릭 위치에서 원형으로 새 테마가 reveal (View Transitions API) // 테마 전환 — 클릭 위치에서 원형으로 새 테마가 reveal (View Transitions API)
const setNextTheme = useCallback((t: "light" | "dark", e?: React.MouseEvent) => { const setNextTheme = useCallback((t: "light" | "dark", e?: React.MouseEvent) => {
@@ -739,24 +795,29 @@ function AppLayoutInner({ children }: AppLayoutProps) {
if (sidebarCollapsed && menu.hasChildren) scheduleFlyoutClose(); if (sidebarCollapsed && menu.hasChildren) scheduleFlyoutClose();
}} }}
> >
<div <MenuItemActions
draggable={isLeaf && !sidebarCollapsed} menuUrl={isLeaf && !sidebarCollapsed ? menu.url : undefined}
onDragStart={(e) => handleMenuDragStart(e, menu)} menuName={menu.name?.trim() || "(이름 없음)"}
className={`v5-si ${isMenuActive(menu) ? "on" : ""} ${level > 0 ? "ml-6" : ""}`}
title={menu.name}
onClick={(e) => {
if (handleCollapsedMenuClick(menu, e)) return;
handleMenuClick(menu);
}}
> >
<span className="ic">{menu.icon}</span> <div
<span className="truncate" title={menu.name || ""}>{menu.name?.trim() || "(이름 없음)"}</span> draggable={isLeaf && !sidebarCollapsed}
{menu.hasChildren && !sidebarCollapsed && ( onDragStart={(e) => handleMenuDragStart(e, menu)}
<span className="ml-auto"> className={`v5-si ${isMenuActive(menu) ? "on" : ""} ${level > 0 ? "ml-6" : ""}`}
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />} title={menu.name}
</span> onClick={(e) => {
)} if (handleCollapsedMenuClick(menu, e)) return;
</div> handleMenuClick(menu);
}}
>
<span className="ic">{menu.icon}</span>
<span className="truncate" title={menu.name || ""}>{menu.name?.trim() || "(이름 없음)"}</span>
{menu.hasChildren && !sidebarCollapsed && (
<span className="ml-auto">
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</span>
)}
</div>
</MenuItemActions>
{/* 플라이아웃 (접힌 상태에서만) */} {/* 플라이아웃 (접힌 상태에서만) */}
{sidebarCollapsed && flyoutMenu?.menu.id === menu.id && menu.hasChildren && ( {sidebarCollapsed && flyoutMenu?.menu.id === menu.id && menu.hasChildren && (
@@ -785,17 +846,22 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<div className={`v5-submenu ${isExpanded ? "expanded" : ""}`}> <div className={`v5-submenu ${isExpanded ? "expanded" : ""}`}>
<div className="v5-submenu-inner"> <div className="v5-submenu-inner">
{menu.children?.map((child: any, idx: number) => ( {menu.children?.map((child: any, idx: number) => (
<div <MenuItemActions
key={child.id} key={child.id}
draggable={!child.hasChildren} menuUrl={!child.hasChildren ? child.url : undefined}
onDragStart={(e) => handleMenuDragStart(e, child)} menuName={child.name}
className={`v5-si v5-sub-item ${isMenuActive(child) ? "on" : ""}`}
style={{ transitionDelay: isExpanded ? `${idx * 30}ms` : "0ms" }}
onClick={() => handleMenuClick(child)}
> >
<span className="ic">{child.icon}</span> <div
<span className="truncate" title={child.name}>{child.name}</span> draggable={!child.hasChildren}
</div> onDragStart={(e) => handleMenuDragStart(e, child)}
className={`v5-si v5-sub-item ${isMenuActive(child) ? "on" : ""}`}
style={{ transitionDelay: isExpanded ? `${idx * 30}ms` : "0ms" }}
onClick={() => handleMenuClick(child)}
>
<span className="ic">{child.icon}</span>
<span className="truncate" title={child.name}>{child.name}</span>
</div>
</MenuItemActions>
))} ))}
</div> </div>
</div> </div>
@@ -1023,6 +1089,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{/* Tweaks — 색상/테마 빠른 접근 (SettingsModal 오픈) */} {/* Tweaks — 색상/테마 빠른 접근 (SettingsModal 오픈) */}
<button <button
ref={tweaksAnchorRef}
className={`v5-hdr-icon${settingsOpen ? " on" : ""}`} className={`v5-hdr-icon${settingsOpen ? " on" : ""}`}
onClick={() => setSettingsOpen((v) => !v)} onClick={() => setSettingsOpen((v) => !v)}
title="Tweaks — 테마 / 색상" title="Tweaks — 테마 / 색상"
@@ -1190,18 +1257,22 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<SettingsModal <SettingsModal
open={settingsOpen} open={settingsOpen}
onOpenChange={setSettingsOpen} onOpenChange={setSettingsOpen}
anchorRef={tweaksAnchorRef}
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
onSidebarCollapsedChange={setSidebarCollapsed} onSidebarCollapsedChange={setSidebarCollapsed}
navOrientation={navOrientation} navOrientation={navOrientation}
onNavOrientationChange={setNavOrientation} onNavOrientationChange={setNavOrientation}
/> />
{/* 전역 대시보드 생성 모달 — 헤더 "대시보드" 버튼에서 열림 */} {/* 전역 대시보드 생성/수정 통합 모달 — 헤더 "+ 대시보드" 버튼 + 우클릭 컨텍스트의 "수정" 둘 다 사용 */}
<CreateDashboardModal <CreateDashboardModal
open={dashCreateOpen} open={dashCreateOpen || !!dashEditTarget}
onClose={closeDashCreate} onClose={handleDashModalClose}
onSubmit={handleDashCreateSubmit} onSubmit={handleDashModalSubmit}
defaultName="새 대시보드" mode={dashEditTarget ? "edit" : "create"}
defaultName={dashEditTarget?.name ?? "새 대시보드"}
defaultIcon={dashEditTarget?.icon}
defaultIsPersonal={dashEditTarget?.is_personal ?? false}
submitting={dashCreateSubmitting} submitting={dashCreateSubmitting}
/> />
</> </>
+20 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Check, Moon, Sun, X, PanelLeftClose, PanelLeft, AlignJustify, AlignHorizontalJustifyCenter } from "lucide-react"; import { Check, Moon, Sun, X, PanelLeftClose, PanelLeft, AlignJustify, AlignHorizontalJustifyCenter } from "lucide-react";
import { useColorTheme, COLOR_THEMES, type ColorTheme } from "@/hooks/useColorTheme"; import { useColorTheme, COLOR_THEMES, type ColorTheme } from "@/hooks/useColorTheme";
@@ -10,6 +10,8 @@ import { animatedColorChange } from "@/lib/colorTransition";
interface SettingsModalProps { interface SettingsModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** 트리거 버튼 ref — 외부 클릭 판정 시 anchor 자체는 외부로 보지 않기 위함. */
anchorRef?: React.RefObject<HTMLElement | null>;
sidebarCollapsed?: boolean; sidebarCollapsed?: boolean;
onSidebarCollapsedChange?: (collapsed: boolean) => void; onSidebarCollapsedChange?: (collapsed: boolean) => void;
navOrientation?: "vertical" | "horizontal"; navOrientation?: "vertical" | "horizontal";
@@ -18,12 +20,13 @@ interface SettingsModalProps {
/** /**
* Tweaks panel — 디자인시스템 `Tweaks` (app.jsx) 포팅. * Tweaks panel — 디자인시스템 `Tweaks` (app.jsx) 포팅.
* 중앙 모달 → 우하단 플로팅 240px 패널로 변경 (2026-04-21). * 중앙 모달 → 헤더 SlidersHorizontal 버튼 anchor popover (2026-04-28배치).
* 키워드 "설정" 유지 (파일명/컴포넌트명). 외부 API 는 props.open/onOpenChange 기존과 동일. * 키워드 "설정" 유지 (파일명/컴포넌트명). 외부 API 는 props.open/onOpenChange 기존과 동일.
*/ */
export function SettingsModal({ export function SettingsModal({
open, open,
onOpenChange, onOpenChange,
anchorRef,
sidebarCollapsed, sidebarCollapsed,
onSidebarCollapsedChange, onSidebarCollapsedChange,
navOrientation, navOrientation,
@@ -32,6 +35,7 @@ export function SettingsModal({
const { color, setColor } = useColorTheme(); const { color, setColor } = useColorTheme();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const panelRef = useRef<HTMLDivElement>(null);
// Escape 로 닫기 // Escape 로 닫기
useEffect(() => { useEffect(() => {
@@ -43,6 +47,19 @@ export function SettingsModal({
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]); }, [open, onOpenChange]);
// 패널 바깥 클릭 시 닫기 — anchor 버튼 클릭은 onClick 으로 토글되므로 무시.
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (panelRef.current?.contains(target)) return;
if (anchorRef?.current?.contains(target)) return;
onOpenChange(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, onOpenChange, anchorRef]);
const handleModeClick = (next: "light" | "dark", e: React.MouseEvent) => { const handleModeClick = (next: "light" | "dark", e: React.MouseEvent) => {
if (next === theme) return; if (next === theme) return;
animatedThemeChange(next, setTheme, { x: e.clientX, y: e.clientY }); animatedThemeChange(next, setTheme, { x: e.clientX, y: e.clientY });
@@ -60,6 +77,7 @@ export function SettingsModal({
return ( return (
<div <div
ref={panelRef}
className={`v5-tweaks-panel${open ? " on" : ""}`} className={`v5-tweaks-panel${open ? " on" : ""}`}
role="dialog" role="dialog"
aria-label="Tweaks" aria-label="Tweaks"
+91 -77
View File
@@ -2,6 +2,7 @@
import { Fragment, useCallback, useRef, useState } from "react"; import { Fragment, useCallback, useRef, useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import { MenuItemActions } from "./MenuItemActions";
type UIMenu = { type UIMenu = {
id: string; id: string;
@@ -23,6 +24,7 @@ type TopNavBarProps = {
* TopNav — 디자인시스템 `TopNav` 포팅 (shell-components.jsx). * TopNav — 디자인시스템 `TopNav` 포팅 (shell-components.jsx).
* invyone 메뉴 트리(최상위 = 섹션)에 맞게 단순화. * invyone 메뉴 트리(최상위 = 섹션)에 맞게 단순화.
* 섹션 hover → flyout (첫 번째 레벨), flyout 아이템에 자식이 있으면 hover 로 2단계 sub-flyout. * 섹션 hover → flyout (첫 번째 레벨), flyout 아이템에 자식이 있으면 hover 로 2단계 sub-flyout.
* leaf 항목 + url 이 대시보드 패턴(/{seq}) 이면 우클릭 컨텍스트 메뉴(이름 변경/삭제) 노출.
*/ */
export function TopNavBar({ menus, isMenuActive, onSelect }: TopNavBarProps) { export function TopNavBar({ menus, isMenuActive, onSelect }: TopNavBarProps) {
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
@@ -67,47 +69,52 @@ export function TopNavBar({ menus, isMenuActive, onSelect }: TopNavBarProps) {
return ( return (
<Fragment key={sec.id}> <Fragment key={sec.id}>
{i > 0 && <span className="v5-hdr-sep" aria-hidden="true" />} {i > 0 && <span className="v5-hdr-sep" aria-hidden="true" />}
<div <MenuItemActions
className={`v5-tn-section ${isActive ? "on" : ""} ${isOpen ? "open" : ""}`} menuUrl={!sec.hasChildren ? sec.url : undefined}
onMouseEnter={() => openNow(sec.id)} menuName={sec.name}
onMouseLeave={scheduleClose}
style={{ animationDelay: `${i * 40}ms` }}
>
<button
type="button"
className="v5-tn-item"
onClick={() => handleSectionClick(sec)}
> >
{sec.icon && <span className="v5-tn-ic">{sec.icon}</span>} <div
<span>{sec.name}</span> className={`v5-tn-section ${isActive ? "on" : ""} ${isOpen ? "open" : ""}`}
{sec.hasChildren && <ChevronDown size={12} />} onMouseEnter={() => openNow(sec.id)}
</button> onMouseLeave={scheduleClose}
style={{ animationDelay: `${i * 40}ms` }}
>
<button
type="button"
className="v5-tn-item"
onClick={() => handleSectionClick(sec)}
>
{sec.icon && <span className="v5-tn-ic">{sec.icon}</span>}
<span>{sec.name}</span>
{sec.hasChildren && <ChevronDown size={12} />}
</button>
{isOpen && sec.hasChildren && ( {isOpen && sec.hasChildren && (
/* /*
flyout 의 onMouseEnter/Leave 는 의도적으로 달지 않음. flyout 의 onMouseEnter/Leave 는 의도적으로 달지 않음.
- flyout 은 section 의 DOM 자식이므로, section 의 mouseleave 는 - flyout 은 section 의 DOM 자식이므로, section 의 mouseleave 는
"마우스가 flyout 과 section 모두 벗어났을 때" 에만 발화함. "마우스가 flyout 과 section 모두 벗어났을 때" 에만 발화함.
- 따라서 섹션 내부 ↔ flyout 이동은 아무 이벤트도 발사되지 않고 유지됨. - 따라서 섹션 내부 ↔ flyout 이동은 아무 이벤트도 발사되지 않고 유지됨.
- 이전엔 flyout mouseleave → scheduleClose 가 타서 섹션 쪽으로 - 이전엔 flyout mouseleave → scheduleClose 가 타서 섹션 쪽으로
위로 이동 시 플라이아웃이 사라지던 버그가 있었음. 위로 이동 시 플라이아웃이 사라지던 버그가 있었음.
*/ */
<div className="v5-tn-flyout"> <div className="v5-tn-flyout">
{sec.children?.map((it, j) => ( {sec.children?.map((it, j) => (
<TnRow <TnRow
key={it.id} key={it.id}
item={it} item={it}
isMenuActive={isMenuActive} isMenuActive={isMenuActive}
onSelect={(x) => { onSelect={(x) => {
onSelect(x); onSelect(x);
setOpenId(null); setOpenId(null);
}} }}
delay={j * 28} delay={j * 28}
/> />
))} ))}
</div>
)}
</div> </div>
)} </MenuItemActions>
</div>
</Fragment> </Fragment>
); );
})} })}
@@ -131,44 +138,51 @@ function TnRow({
const isOn = isMenuActive(item); const isOn = isMenuActive(item);
return ( return (
<div <MenuItemActions menuUrl={!hasChildren ? item.url : undefined} menuName={item.name}>
className={`v5-tn-row ${isOn ? "on" : ""} ${hasChildren ? "has-sub" : ""}`} <div
style={{ animationDelay: `${delay}ms` }} className={`v5-tn-row ${isOn ? "on" : ""} ${hasChildren ? "has-sub" : ""}`}
onMouseEnter={() => hasChildren && setSubOpen(true)} style={{ animationDelay: `${delay}ms` }}
onMouseLeave={() => setSubOpen(false)} onMouseEnter={() => hasChildren && setSubOpen(true)}
onClick={(e) => { onMouseLeave={() => setSubOpen(false)}
if ((e.target as HTMLElement).closest(".v5-tn-sub")) return; onClick={(e) => {
if (hasChildren && item.children?.[0]) onSelect(item.children[0]); if ((e.target as HTMLElement).closest(".v5-tn-sub")) return;
else onSelect(item); if (hasChildren && item.children?.[0]) onSelect(item.children[0]);
}} else onSelect(item);
> }}
{item.icon && <span className="v5-tn-ic">{item.icon}</span>} >
<span className="v5-tn-row-label">{item.name}</span> {item.icon && <span className="v5-tn-ic">{item.icon}</span>}
{typeof item.badge === "number" && item.badge > 0 && ( <span className="v5-tn-row-label">{item.name}</span>
<span className="v5-tn-badge">{item.badge}</span> {typeof item.badge === "number" && item.badge > 0 && (
)} <span className="v5-tn-badge">{item.badge}</span>
{hasChildren && <ChevronRight size={12} />} )}
{hasChildren && subOpen && ( {hasChildren && <ChevronRight size={12} />}
<div className="v5-tn-sub"> {hasChildren && subOpen && (
{item.children?.map((c, k) => ( <div className="v5-tn-sub">
<div {item.children?.map((c, k) => (
key={c.id} <MenuItemActions
className={`v5-tn-row v5-tn-sub-row ${isMenuActive(c) ? "on" : ""}`} key={c.id}
style={{ animationDelay: `${k * 22}ms` }} menuUrl={!c.hasChildren ? c.url : undefined}
onClick={(e) => { menuName={c.name}
e.stopPropagation(); >
onSelect(c); <div
}} className={`v5-tn-row v5-tn-sub-row ${isMenuActive(c) ? "on" : ""}`}
> style={{ animationDelay: `${k * 22}ms` }}
{c.icon && <span className="v5-tn-ic">{c.icon}</span>} onClick={(e) => {
<span className="v5-tn-row-label">{c.name}</span> e.stopPropagation();
{typeof c.badge === "number" && c.badge > 0 && ( onSelect(c);
<span className="v5-tn-badge">{c.badge}</span> }}
)} >
</div> {c.icon && <span className="v5-tn-ic">{c.icon}</span>}
))} <span className="v5-tn-row-label">{c.name}</span>
</div> {typeof c.badge === "number" && c.badge > 0 && (
)} <span className="v5-tn-badge">{c.badge}</span>
</div> )}
</div>
</MenuItemActions>
))}
</div>
)}
</div>
</MenuItemActions>
); );
} }
+10
View File
@@ -13,6 +13,11 @@ const authLog = (event: string, detail: string) => {
// API URL 동적 설정 // API URL 동적 설정
// 우선순위: 1) 테넌트 서브도메인 → 직접 백엔드 2) 프로덕션 도메인 3) NEXT_PUBLIC_API_URL 4) default // 우선순위: 1) 테넌트 서브도메인 → 직접 백엔드 2) 프로덕션 도메인 3) NEXT_PUBLIC_API_URL 4) default
//
// dev 가짜 서브도메인: <prefix>.<IPv4>(.nip.io|.sslip.io)? — Next rewrite 가 Host 변조하므로
// 같은 호스트 + backend 노출 포트(:8083) 로 직접 호출해 Host 보존.
const DEV_TENANT_HOST = /^[a-z0-9-]+\.\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?:\.(?:nip|sslip)\.io)?$/;
const getApiBaseUrl = (): string => { const getApiBaseUrl = (): string => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const currentHost = window.location.hostname; const currentHost = window.location.hostname;
@@ -24,6 +29,11 @@ const getApiBaseUrl = (): string => {
return `https://${currentHost}/api`; return `https://${currentHost}/api`;
} }
// 1-b. dev 가짜 서브도메인 (사무실 도커 등). 운영 Traefik 없으니 backend 노출 포트로 직접.
if (DEV_TENANT_HOST.test(currentHost)) {
return `http://${currentHost}:8083/api`;
}
// 2. 프로덕션 메인 도메인 fallback (1번에서 endsWith 로 이미 처리되므로 dead-code 가깝지만, // 2. 프로덕션 메인 도메인 fallback (1번에서 endsWith 로 이미 처리되므로 dead-code 가깝지만,
// invyone.com 루트 도메인 등 예외 케이스 보호용으로 유지) // invyone.com 루트 도메인 등 예외 케이스 보호용으로 유지)
if (currentHost === "v1.invyone.com" || currentHost === "solution.invyone.com") { if (currentHost === "v1.invyone.com" || currentHost === "solution.invyone.com") {
+18 -5
View File
@@ -22,16 +22,29 @@ const RESERVED_MAIN = new Set([
"console", "console",
]); ]);
// 개발 환경 가짜 서브도메인 패턴: <prefix>.<IPv4>(.nip.io | .sslip.io)?
// 사무실 도커처럼 운영 와일드카드 DNS 가 없는 환경에서, hosts 매핑 또는 nip.io 외부 DNS 로 prefix 를 표현.
// backend SubdomainResolverFilter 도 동일 의도로 호스트 첫 라벨을 prefix 로 추출.
const DEV_TENANT_HOST = /^([a-z0-9-]+)\.\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?:\.(?:nip|sslip)\.io)?$/;
export function extractTenantSubdomain(host: string): string | null { export function extractTenantSubdomain(host: string): string | null {
if (!host) return null; if (!host) return null;
const cleanHost = host.split(":")[0].toLowerCase(); const cleanHost = host.split(":")[0].toLowerCase();
if (!cleanHost.endsWith(".invyone.com")) return null;
const prefix = cleanHost.substring(0, cleanHost.length - ".invyone.com".length); if (cleanHost.endsWith(".invyone.com")) {
if (!prefix) return null; const prefix = cleanHost.substring(0, cleanHost.length - ".invyone.com".length);
if (!prefix) return null;
if (RESERVED_MAIN.has(prefix)) return null;
return prefix;
}
if (RESERVED_MAIN.has(prefix)) return null; const devMatch = cleanHost.match(DEV_TENANT_HOST);
if (devMatch) {
const prefix = devMatch[1];
if (RESERVED_MAIN.has(prefix)) return null;
return prefix;
}
return prefix; return null;
} }
+445 -99
View File
@@ -375,81 +375,315 @@ body { display: flex; flex-direction: column; }
EMERGENCY SCENARIO — 경고시스템 시연 영역 EMERGENCY SCENARIO — 경고시스템 시연 영역
============================================================ */ ============================================================ */
/* 경고 버튼 (사이드바) */ /* 경고 버튼 (사이드바) — voice-btn 과 동일 패턴, 핑크 액센트 */
.controls-row button.emergency { .controls-row button.emergency {
background: linear-gradient(180deg, #5a102c, #2a0a14); position: relative; overflow: hidden;
display: flex; align-items: center; gap: 10px;
width: 100%;
padding: 11px 14px;
background: linear-gradient(135deg, rgba(255,79,154,0.10), rgba(255,79,154,0.02) 60%, transparent);
color: #ff8eb6; color: #ff8eb6;
border: 1px solid #ff4f9a; border: 1px solid rgba(255,79,154,0.50);
font-weight: 700; border-radius: 6px;
letter-spacing: 0.5px; font-size: 12px; font-weight: 700;
padding: 10px; letter-spacing: 0.12em;
font-size: 12px; text-shadow: none;
text-shadow: 0 0 4px #ff4f9a; cursor: pointer;
box-shadow: 0 0 0 0 rgba(255, 79, 154, 0.7); transition: background 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
animation: emergencyPulse 2.5s ease-out infinite; box-shadow:
inset 0 0 12px rgba(255,79,154,0.10),
0 0 8px rgba(255,79,154,0.20);
}
.controls-row button.emergency::before {
content: ""; position: absolute;
top: 0; left: 8%; right: 8%; height: 1px;
background: linear-gradient(90deg, transparent, #ff4f9a, transparent);
opacity: 0.7;
} }
.controls-row button.emergency:hover { .controls-row button.emergency:hover {
background: linear-gradient(180deg, #7a1a3c, #3a1a24); background: linear-gradient(135deg, rgba(255,79,154,0.22), rgba(255,79,154,0.08) 60%, transparent);
border-color: #ff4f9a; color: #fff;
box-shadow:
inset 0 0 18px rgba(255,79,154,0.20),
0 0 18px rgba(255,79,154,0.45);
}
.controls-row button.emergency .ico {
width: 16px; height: 16px; flex-shrink: 0;
filter: drop-shadow(0 0 4px rgba(255,79,154,0.6));
}
.controls-row button.emergency .lbl { flex: 1; text-align: left; }
.controls-row button.emergency .dot {
width: 7px; height: 7px; border-radius: 50%;
background: #ff4f9a;
box-shadow: 0 0 6px rgba(255,79,154,0.7);
flex-shrink: 0;
}
/* 다중 경고 — 같은 톤이지만 주황 액센트 (정적) */
.controls-row button.emergency.multi {
background: linear-gradient(135deg, rgba(255,79,154,0.10), rgba(255,138,58,0.10) 60%, transparent);
color: #ffb380;
border-color: rgba(255,138,58,0.55);
box-shadow:
inset 0 0 14px rgba(255,138,58,0.14),
0 0 10px rgba(255,138,58,0.28);
}
.controls-row button.emergency.multi::before {
background: linear-gradient(90deg, transparent, #ff8a3a, transparent);
opacity: 1;
}
.controls-row button.emergency.multi:hover {
background: linear-gradient(135deg, rgba(255,79,154,0.22), rgba(255,138,58,0.18) 60%, transparent);
border-color: #ff8a3a; color: #fff;
box-shadow:
inset 0 0 22px rgba(255,138,58,0.22),
0 0 22px rgba(255,138,58,0.55);
}
.controls-row button.emergency.multi .ico {
filter: drop-shadow(0 0 5px rgba(255,138,58,0.75));
}
.controls-row button.emergency.multi .dot {
background: #ff8a3a;
box-shadow: 0 0 8px rgba(255,138,58,0.8);
}
/* 다중 경고 — 메인 모달 바로 아래 가로 stack, 메인 모달 톤의 mini-modal 카드 */
.alarm-list-dock {
position: fixed;
top: calc(50% + 31vh + 14px); /* 메인 모달(with-multi: max-height 62vh) 의 bottom + gap */
bottom: auto;
left: calc(50% - 732px); /* 메인 모달과 좌측 정렬 */
transform: none;
width: 960px;
max-width: calc(100vw - 48px);
height: auto;
background: transparent;
border: none;
z-index: 103;
display: none;
flex-direction: row;
gap: 14px;
font-family: Consolas, monospace;
box-shadow: none;
}
.alarm-list-dock.show { display: flex; }
.alarm-list-dock > header { display: none; }
#alarm-list-rows {
display: flex;
flex-direction: row;
gap: 14px;
width: 100%;
align-items: stretch;
}
/* mini-modal — 메인 emergency-modal 의 컴팩트 버전 */
.alarm-mini-modal {
flex: 1;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #081326 0%, #050a18 100%);
border: 1px solid #2a3f5a;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
font-family: Consolas, monospace;
}
.alarm-mini-modal.severity-high { border-left: 4px solid #ff8a3a; }
.alarm-mini-modal.severity-medium { border-left: 3px solid #ffd400; }
/* head */
.alarm-mini-modal .mini-head {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: #0d1a2e;
border-bottom: 1px solid #2a3f5a;
}
.alarm-mini-modal .mini-head-left { display: flex; gap: 6px; align-items: center; }
.alarm-mini-modal .mini-severity {
padding: 2px 8px;
font-size: 10px;
font-weight: 800;
letter-spacing: 1.5px;
color: #fff; color: #fff;
} }
.alarm-mini-modal .mini-severity.severity-high { background: #ff8a3a; color: #000; }
.alarm-mini-modal .mini-severity.severity-medium { background: #ffd400; color: #000; }
.alarm-mini-modal .mini-code {
padding: 2px 6px;
border: 1px solid #3a5278;
font-size: 10px;
color: #cfd3d8;
}
.alarm-mini-modal .mini-title {
flex: 1;
color: #fff;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.alarm-mini-modal .mini-close {
background: transparent;
border: none;
color: #ff8eb6;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.alarm-mini-modal .mini-close:hover { color: #fff; }
/* location bar — 메인 모달 .modal-location 톤 */
.alarm-mini-modal .mini-location {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px;
background: #0a1628;
border-bottom: 1px solid #1a2a44;
font-size: 11px;
color: var(--text-muted);
}
.alarm-mini-modal .mini-location .mini-loc-icon { color: #ff4f9a; }
.alarm-mini-modal .mini-location b { color: #fff; font-weight: 700; font-family: Consolas, monospace; }
.alarm-mini-modal .mini-location .mini-loc-time { margin-left: auto; color: #5af9ff; font-size: 10px; }
/* body — 알람 상세 */
.alarm-mini-modal .mini-message {
flex: 1;
padding: 10px 12px;
border-bottom: 1px solid #2a3f5a;
}
.alarm-mini-modal .mini-msg-label {
font-size: 10px;
color: #ff8eb6;
font-weight: 700;
margin-bottom: 4px;
letter-spacing: 0.5px;
}
.alarm-mini-modal .mini-msg-text {
font-size: 12px;
color: #fff;
line-height: 1.5;
}
.alarm-mini-modal.severity-medium .mini-msg-text { color: #cfd3d8; }
/* footer */
.alarm-mini-modal .mini-foot {
display: flex;
gap: 8px;
padding: 8px 12px;
background: var(--bg-darker);
}
.alarm-mini-modal .mini-btn {
padding: 6px 12px;
background: var(--bg-darker);
border: 1px solid var(--stroke);
font-size: 11px;
font-family: inherit;
font-weight: 700;
letter-spacing: 0.5px;
cursor: pointer;
}
.alarm-mini-modal .mini-btn-skip { color: var(--text-muted); }
.alarm-mini-modal .mini-btn-skip:hover { color: var(--text); border-color: #fff; }
.alarm-mini-modal .mini-btn-ack {
margin-left: auto;
background: var(--active);
color: #000;
border-color: var(--active);
}
.alarm-mini-modal .mini-btn-ack:hover { background: #5fff20; }
@keyframes emergencyPulse { @keyframes emergencyPulse {
0% { box-shadow: 0 0 0 0 rgba(255, 79, 154, 0.6); } 0% { box-shadow: 0 0 0 0 rgba(255, 79, 154, 0.6); }
70% { box-shadow: 0 0 0 12px rgba(255, 79, 154, 0); } 70% { box-shadow: 0 0 0 12px rgba(255, 79, 154, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 79, 154, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 79, 154, 0); }
} }
/* P&ID 안의 시나리오 타겟 강조 — 강력하게 */ /* P&ID 안의 시나리오 타겟 강조 — 빨간 정적 glow */
.comp.scenario-target { filter: drop-shadow(0 0 8px var(--alarm)); } .comp.scenario-target { filter: drop-shadow(0 0 6px rgba(220,28,46,0.95)); }
@keyframes scenarioFlashStrong { .comp.scenario-flash { /* brightness 깜빡임 제거 — ⚠ 핀의 ring pulse 가 알람 식별 담당 */ }
0%, 100% {
filter: drop-shadow(0 0 8px var(--alarm)) brightness(1.0) saturate(1.0);
}
50% {
filter: drop-shadow(0 0 24px var(--alarm)) drop-shadow(0 0 8px #fff) brightness(1.45) saturate(1.4);
}
}
.comp.scenario-flash { animation: scenarioFlashStrong 0.85s ease-in-out infinite; }
/* P&ID 알람 핀 — 펌프 위에 떠있는 큰 ⚠ 마커 */ /* P&ID 알람 핀 — severity 별 시각 위계 */
.scenario-alarm-pin .pin-ring-1, .scenario-alarm-pin .pin-ring-1,
.scenario-alarm-pin .pin-ring-2 { .scenario-alarm-pin .pin-ring-2,
.scenario-alarm-pin .pin-core {
transform-box: fill-box; transform-box: fill-box;
transform-origin: center; transform-origin: center;
} }
@keyframes pinRingPulse { @keyframes pinRingPulse {
0% { transform: scale(0.9); opacity: 0.7; } 0% { transform: scale(0.85); opacity: 0.7; }
100% { transform: scale(1.6); opacity: 0; } 100% { transform: scale(1.9); opacity: 0; }
} }
.scenario-alarm-pin .pin-ring-1 { animation: pinRingPulse 1.4s ease-out infinite; } @keyframes pinCoreBeat {
.scenario-alarm-pin .pin-ring-2 { animation: pinRingPulse 1.4s ease-out 0.5s infinite; } 0%, 100% { transform: scale(1); }
@keyframes pinIconBlink { 50% { transform: scale(1.08); }
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
} }
.scenario-alarm-pin .pin-core { animation: pinIconBlink 0.6s infinite; } /* CRITICAL — 가장 강조: ring pulse 두 개 stagger + core beat */
.scenario-alarm-pin.severity-critical .pin-ring-1 { animation: pinRingPulse 1.6s ease-out infinite; }
.scenario-alarm-pin.severity-critical .pin-ring-2 { animation: pinRingPulse 1.6s ease-out 0.8s infinite; }
.scenario-alarm-pin.severity-critical .pin-core { animation: pinCoreBeat 1.1s ease-in-out infinite; }
/* HIGH — 보조: ring 한 개만 약하게 pulse, core 정적 */
.scenario-alarm-pin.severity-high .pin-ring-1 { animation: pinRingPulse 2.4s ease-out infinite; }
.scenario-alarm-pin.severity-high .pin-ring-2 { display: none; }
/* MEDIUM — 가장 약함: 모두 정적 */
.scenario-alarm-pin.severity-medium .pin-ring-1,
.scenario-alarm-pin.severity-medium .pin-ring-2 { display: none; }
/* hazard 점선 inner 박스 — 천천히 march */
@keyframes hazardMarch {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -14; }
}
.scenario-alarm-rect-inner { animation: hazardMarch 1.2s linear infinite; }
/* ============= 전체 화면 CRITICAL 이펙트 (절제) ============= */
body.critical-alarm::before {
content: "";
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
z-index: 90; /* 모달(102), 맵(101) 아래 */
box-shadow: inset 0 0 140px 30px rgba(220, 28, 46, 0.22);
border: 2px solid rgba(220, 28, 46, 0.65);
animation: criticalVignette 1.8s ease-in-out infinite;
transition: right 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
/* 맵 panel 이 열려있으면 vignette 자체 숨김 (모달 + 맵 둘 다 떠있으면 위험 알림은 모달 + 핀으로 충분) */
body.critical-alarm.map-open::before { display: none; }
@keyframes criticalVignette {
0%, 100% {
box-shadow: inset 0 0 130px 25px rgba(220, 28, 46, 0.18);
border-color: rgba(220, 28, 46, 0.55);
}
50% {
box-shadow: inset 0 0 170px 40px rgba(220, 28, 46, 0.32);
border-color: rgba(220, 28, 46, 0.85);
}
}
/* (z-index 우선순위는 .map-panel 정의 위치(아래)에서 직접 박음 — cascade override 방지) */
/* 상단 알람 배너 */ /* 상단 알람 배너 */
.alarm-banner { .alarm-banner {
position: fixed; position: fixed;
top: 50px; top: 50px;
left: 0; right: 0; left: 0; right: 0;
background: linear-gradient(90deg, #5a102c 0%, #ff4f9a 50%, #5a102c 100%); background: linear-gradient(90deg, #3a0e1e 0%, #8a3258 50%, #3a0e1e 100%);
background-size: 200% 100%; background-size: 200% 100%;
padding: 11px 24px; padding: 11px 24px;
border-bottom: 2px solid #ff4f9a; border-bottom: 1px solid #5a3548;
border-top: 1px solid rgba(255,255,255,0.2); border-top: 1px solid rgba(255,255,255,0.08);
color: #fff; color: #fff;
display: flex; align-items: center; gap: 14px; align-items: center; gap: 14px;
font-family: Consolas, monospace; font-family: Consolas, monospace;
z-index: 105; /* 모달(100) 위, 핸드폰(110) 아래 */ z-index: 105; /* 모달(100) 위, 핸드폰(110) 아래 */
transform: translateY(-110%); display: none;
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), right 0.6s cubic-bezier(0.22, 1, 0.36, 1); pointer-events: none;
box-shadow: 0 4px 18px rgba(255, 79, 154, 0.4); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
} }
.alarm-banner.with-map { right: 540px; } /* 맵 모달이 가운데 정렬 별도 모달이라 배너는 fullscreen 유지 (right shift 제거) */
.alarm-banner.with-map { right: 0; }
.alarm-banner.show { .alarm-banner.show {
transform: translateY(0); display: flex;
animation: bannerScroll 4s linear infinite; pointer-events: auto;
animation: bannerScroll 8s linear infinite;
} }
@keyframes bannerScroll { @keyframes bannerScroll {
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
@@ -523,30 +757,31 @@ body { display: flex; flex-direction: column; }
============================================================ */ ============================================================ */
.map-panel { .map-panel {
position: fixed; position: fixed;
top: 50px; top: 50%;
right: 0; left: calc(50% + 252px);
bottom: 160px; width: 480px;
width: 540px; max-height: 84vh;
background: linear-gradient(180deg, #050a18 0%, #081326 100%); height: auto;
border-left: 2px solid #5af9ff; background: linear-gradient(180deg, #081326 0%, #050a18 100%);
display: flex; border: 1px solid #2a3f5a;
display: none;
flex-direction: column; flex-direction: column;
transform: translateX(100%); transform: translateY(-50%);
transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1); z-index: 103; /* 메인 모달 backdrop(102) 위 — 나란히 sibling 모달 */
z-index: 50; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
box-shadow: -8px 0 32px rgba(90, 249, 255, 0.15); box-shadow: -8px 0 32px rgba(90, 249, 255, 0.15);
} }
.map-panel.open { transform: translateX(0); } .map-panel.open { display: flex; }
.map-panel-head { .map-panel-head {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px; padding: 12px 18px;
background: linear-gradient(180deg, #0b1a35, #050a18); background: #0d1a2e;
border-bottom: 1px solid #3a5278; border-bottom: 1px solid #2a3f5a;
flex-shrink: 0; flex-shrink: 0;
} }
.map-panel-title { color: #5af9ff; font-size: 13px; font-weight: 700; letter-spacing: 1px; } .map-panel-title { color: #fff; font-size: 13px; font-weight: 700; letter-spacing: 1px; }
.map-panel-status { color: #7cff3a; font-size: 10px; font-family: Consolas, monospace; } .map-panel-status { color: #cfd3d8; font-size: 10px; font-family: Consolas, monospace; }
.map-panel-status::before { content: "● "; } .map-panel-status::before { content: "● "; }
.map-panel-body { flex: 1; min-height: 0; padding: 10px; } .map-panel-body { flex: 1; min-height: 0; padding: 10px; }
@@ -607,13 +842,13 @@ body { display: flex; flex-direction: column; }
.phone-notify { .phone-notify {
position: fixed; position: fixed;
top: 70px; top: 70px;
right: -340px; right: 30px;
width: 280px; width: 280px;
z-index: 110; z-index: 110;
transition: right 0.5s cubic-bezier(0.22, 1, 0.36, 1); display: none;
pointer-events: none; pointer-events: none;
} }
.phone-notify.show { right: 30px; pointer-events: auto; } .phone-notify.show { display: block; pointer-events: auto; }
.phone-notify.shake .phone-frame { animation: phoneShake 0.4s ease-in-out 0s 8; } .phone-notify.shake .phone-frame { animation: phoneShake 0.4s ease-in-out 0s 8; }
@keyframes phoneShake { @keyframes phoneShake {
0%, 100% { transform: translateX(0) rotate(0); } 0%, 100% { transform: translateX(0) rotate(0); }
@@ -699,50 +934,61 @@ body { display: flex; flex-direction: column; }
.emergency-modal-backdrop { .emergency-modal-backdrop {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.65); background: rgba(2, 6, 16, 0.88);
backdrop-filter: blur(2px); backdrop-filter: blur(5px);
display: none; display: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 100; z-index: 102; /* critical-alarm(90) + map-panel(101) 위 */
animation: modalFadeIn 0.3s ease-out;
transition: right 0.6s cubic-bezier(0.22, 1, 0.36, 1);
} }
.emergency-modal-backdrop.show { display: flex; } .emergency-modal-backdrop.show { display: flex; }
/* 맵이 열리면 backdrop을 좌측 영역으로 좁혀 맵이 보이게 함 */ /* 맵 + 다중 카드 떠있으면 메인+맵 위쪽 정렬 (높이 65vh), 하단 22vh 에 보조 카드 stack */
.emergency-modal-backdrop.with-map { right: 540px; } .emergency-modal-backdrop.with-map .emergency-modal {
@keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } } position: fixed;
top: 50%;
left: calc(50% - 732px);
transform: translateY(-50%);
width: 960px;
max-width: none;
max-height: 70vh;
height: auto;
}
/* 다중 경고 — with-map 과 같은 좌측 정렬 + dock 공간 확보 위해 max-height 압축 */
.emergency-modal-backdrop.with-multi .emergency-modal,
.emergency-modal-backdrop.with-multi.with-map .emergency-modal {
position: fixed;
top: 50%;
left: calc(50% - 732px);
transform: translateY(-50%);
width: 960px;
max-width: none;
max-height: 62vh;
height: auto;
}
.emergency-modal { .emergency-modal {
width: min(960px, 92vw); width: min(960px, 92vw);
max-height: 88vh; max-height: 88vh;
background: linear-gradient(180deg, #081326 0%, #050a18 100%); background: linear-gradient(180deg, #081326 0%, #050a18 100%);
border: 2px solid #ff4f9a; border: 1px solid #2a3f5a;
box-shadow: 0 0 40px rgba(255,79,154,0.4), inset 0 0 20px rgba(255,79,154,0.05); box-shadow: 0 8px 28px rgba(0,0,0,0.5);
display: flex; flex-direction: column; display: flex; flex-direction: column;
animation: modalSlideIn 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes modalSlideIn {
from { transform: translateY(-20px) scale(0.96); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
} }
.modal-head { .modal-head {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 12px 18px; padding: 12px 18px;
background: linear-gradient(90deg, #5a102c, #3a0e1e); background: #0d1a2e;
border-bottom: 1px solid #ff4f9a; border-bottom: 1px solid #2a3f5a;
} }
.modal-head-left { display: flex; gap: 8px; align-items: center; } .modal-head-left { display: flex; gap: 8px; align-items: center; }
.modal-severity { .modal-severity {
background: #ff4f9a; color: #fff; background: #b8466e; color: #fff;
padding: 3px 10px; font-size: 11px; font-weight: 700; padding: 3px 10px; font-size: 11px; font-weight: 700;
letter-spacing: 1.5px; letter-spacing: 1.5px;
animation: pulse-alarm 0.8s infinite;
} }
.modal-code { .modal-code {
font-family: Consolas, monospace; color: #ff8eb6; font-family: Consolas, monospace; color: #cfd3d8;
font-size: 11px; padding: 3px 8px; border: 1px solid #ff4f9a; font-size: 11px; padding: 3px 8px; border: 1px solid #3a5278;
} }
.modal-head-title { flex: 1; color: #fff; font-size: 16px; font-weight: 700; } .modal-head-title { flex: 1; color: #fff; font-size: 16px; font-weight: 700; }
.modal-close { .modal-close {
@@ -769,8 +1015,7 @@ body { display: flex; flex-direction: column; }
font-family: Consolas, monospace; font-family: Consolas, monospace;
} }
.cctv-rec { .cctv-rec {
color: #ff4f9a; font-weight: 700; color: #d05880; font-weight: 700;
animation: pulse-alarm 1.2s infinite;
} }
.cctv-cam { flex: 1; color: #5af9ff; } .cctv-cam { flex: 1; color: #5af9ff; }
.cctv-time { color: var(--text); } .cctv-time { color: var(--text); }
@@ -822,13 +1067,25 @@ body { display: flex; flex-direction: column; }
.cctv-target-box { .cctv-target-box {
position: absolute; top: 30%; left: 30%; position: absolute; top: 30%; left: 30%;
width: 40%; height: 40%; width: 40%; height: 40%;
border: 2px solid #ff4f9a; border: 1px dashed rgba(255,158,184,0.55);
box-shadow: 0 0 12px rgba(255,79,154,0.5);
animation: pulse-alarm 1.2s infinite;
} }
.cctv-alert-icon {
position: absolute; top: 10px; right: 10px;
display: flex; gap: 6px; align-items: center;
padding: 4px 9px;
background: rgba(184,70,110,0.85);
color: #fff;
font-size: 11px; font-weight: 700;
font-family: Consolas, monospace;
letter-spacing: 1px;
border-radius: 2px;
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
z-index: 5;
}
.cctv-alert-icon svg { display: block; }
.cctv-target-label { .cctv-target-label {
position: absolute; top: -22px; left: 0; position: absolute; top: -20px; left: 0;
background: #ff4f9a; color: #000; background: rgba(184,70,110,0.85); color: #fff;
padding: 2px 8px; font-size: 10px; font-weight: 700; padding: 2px 8px; font-size: 10px; font-weight: 700;
font-family: Consolas, monospace; font-family: Consolas, monospace;
} }
@@ -851,7 +1108,7 @@ body { display: flex; flex-direction: column; }
background: #0a1628; color: var(--text-muted); background: #0a1628; color: var(--text-muted);
font-family: Consolas, monospace; font-family: Consolas, monospace;
} }
.cctv-live { margin-left: auto; color: #ff4f9a; } .cctv-live { margin-left: auto; color: #d05880; }
/* 담당자 영역 (우) */ /* 담당자 영역 (우) */
.modal-officer { .modal-officer {
@@ -908,28 +1165,28 @@ body { display: flex; flex-direction: column; }
.officer-action { .officer-action {
margin-top: auto; margin-top: auto;
padding: 12px; padding: 12px;
background: linear-gradient(180deg, #ff4f9a, #5a102c); background: #1a2a45;
color: #fff; color: #fff;
border: 1px solid #ff4f9a; border: 1px solid #3a5278;
font-size: 13px; font-weight: 700; font-size: 13px; font-weight: 600;
letter-spacing: 1px; letter-spacing: 1px;
cursor: pointer; cursor: pointer;
display: flex; gap: 8px; align-items: center; justify-content: center; display: flex; gap: 8px; align-items: center; justify-content: center;
animation: pulse-alarm 1.6s infinite; transition: background 0.2s, border-color 0.2s;
} }
.officer-action:hover { background: #ff4f9a; } .officer-action:hover { background: #243553; border-color: #5af9ff; }
.dispatch-icon { font-size: 16px; } .dispatch-icon { font-size: 16px; }
/* 모달 알람 메시지 (하단) */ /* 모달 알람 메시지 (하단) */
.modal-alarm-message { .modal-alarm-message {
margin: 0 16px 12px; margin: 0 16px 12px;
padding: 12px; padding: 12px;
background: rgba(90, 16, 44, 0.4); background: rgba(20, 30, 50, 0.5);
border-left: 4px solid #ff4f9a; border-left: 2px solid rgba(184,70,110,0.7);
} }
.alarm-msg-label { .alarm-msg-label {
font-size: 10px; font-weight: 700; font-size: 10px; font-weight: 700;
color: #ff4f9a; letter-spacing: 1.5px; color: #d05880; letter-spacing: 1.5px;
margin-bottom: 4px; margin-bottom: 4px;
} }
.alarm-msg-text { .alarm-msg-text {
@@ -961,3 +1218,92 @@ body { display: flex; flex-direction: column; }
} }
.modal-btn-ack:enabled:hover { background: #5fff20; } .modal-btn-ack:enabled:hover { background: #5fff20; }
.modal-btn-ack:disabled { cursor: not-allowed; } .modal-btn-ack:disabled { cursor: not-allowed; }
/* ============================================================
* 음성 컨트롤 (voice.js)
* SCADA 톤 — 청록 액센트 + 글로우 + 상단 라이트 라인
* ============================================================ */
.voice-wrap{margin-top:10px; display:flex; flex-direction:column; gap:4px}
.voice-btn{
position:relative; overflow:hidden;
display:flex; align-items:center; gap:10px;
width:100%;
padding:11px 14px;
background:linear-gradient(135deg, rgba(90,249,255,0.08), rgba(90,249,255,0.02) 60%, transparent);
color:#5af9ff;
border:1px solid rgba(90,249,255,0.45);
border-radius:6px;
cursor:pointer;
font-size:12px; font-weight:700;
letter-spacing:0.12em;
transition:background 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
box-shadow:inset 0 0 12px rgba(90,249,255,0.08), 0 0 8px rgba(90,249,255,0.18);
}
.voice-btn::before{
content:""; position:absolute;
top:0; left:8%; right:8%; height:1px;
background:linear-gradient(90deg, transparent, #5af9ff, transparent);
opacity:0.7;
}
.voice-btn:hover:not(:disabled){
background:linear-gradient(135deg, rgba(90,249,255,0.18), rgba(90,249,255,0.06) 60%, transparent);
border-color:#5af9ff; color:#fff;
box-shadow:inset 0 0 18px rgba(90,249,255,0.18), 0 0 18px rgba(90,249,255,0.4);
}
.voice-btn:disabled{opacity:0.35; cursor:not-allowed; box-shadow:none}
.voice-btn:disabled::before{display:none}
.voice-icon{
width:16px; height:16px; flex-shrink:0;
filter:drop-shadow(0 0 4px rgba(90,249,255,0.5));
}
.voice-label{flex:1; text-align:left}
.voice-status-dot{
width:7px; height:7px; border-radius:50%;
background:#5af9ff; opacity:0.4;
box-shadow:0 0 6px rgba(90,249,255,0.6);
flex-shrink:0;
}
/* listening 상태 — 빨강/주황 위험톤 + 강한 펄스 */
.voice-btn.listening{
background:linear-gradient(135deg, rgba(255,45,107,0.28), rgba(255,138,58,0.18) 60%, transparent);
color:#fff; border-color:#ff8a3a;
animation:voice-pulse 1.2s ease-in-out infinite;
}
.voice-btn.listening::before{
background:linear-gradient(90deg, transparent, #ff2d6b, transparent);
opacity:1;
}
.voice-btn.listening .voice-icon{
filter:drop-shadow(0 0 6px rgba(255,45,107,0.8));
}
.voice-btn.listening .voice-status-dot{
background:#ff2d6b; opacity:1;
box-shadow:0 0 10px rgba(255,45,107,0.9);
animation:voice-dot-pulse 0.9s ease-in-out infinite;
}
@keyframes voice-pulse{
0%,100%{box-shadow:inset 0 0 18px rgba(255,138,58,0.22), 0 0 14px rgba(255,138,58,0.5)}
50% {box-shadow:inset 0 0 24px rgba(255,45,107,0.4), 0 0 28px rgba(255,45,107,0.85)}
}
@keyframes voice-dot-pulse{
0%,100%{transform:scale(1); opacity:1}
50% {transform:scale(1.4); opacity:0.6}
}
.voice-transcript{
font-size:11px; color:#5af9ff; min-height:14px;
padding:3px 6px; font-style:italic;
font-variant-numeric:tabular-nums;
letter-spacing:0.02em;
}
/* 매칭된 컴포넌트 청록 펄스 (1.4s) */
.voice-flash{animation:voice-flash 1.4s ease-out 1}
@keyframes voice-flash{
0% {filter:drop-shadow(0 0 0 #5af9ff)}
25% {filter:drop-shadow(0 0 18px #5af9ff)}
60% {filter:drop-shadow(0 0 8px #5af9ff)}
100% {filter:drop-shadow(0 0 0 #5af9ff)}
}
+38 -3
View File
@@ -84,7 +84,28 @@
<button id="btn-alarm" class="warn">⚠ 알람 데모</button> <button id="btn-alarm" class="warn">⚠ 알람 데모</button>
</div> </div>
<div class="controls-row" style="margin-top:10px"> <div class="controls-row" style="margin-top:10px">
<button id="btn-emergency" class="emergency">🚨 경고시스템 시연</button> <button id="btn-emergency" class="emergency">
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3 L22 20 L2 20 Z"/>
<line x1="12" y1="10" x2="12" y2="14.5"/>
<circle cx="12" cy="17.4" r="0.6" fill="currentColor" stroke="none"/>
</svg>
<span class="lbl">경고시스템</span>
<span class="dot" aria-hidden="true"></span>
</button>
</div>
<div class="controls-row" style="margin-top:6px">
<button id="btn-multi-emergency" class="emergency multi">
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 8 L13 18 L1 18 Z" opacity="0.55"/>
<path d="M17 8 L23 18 L11 18 Z" opacity="0.55"/>
<path d="M12 3 L22 20 L2 20 Z"/>
<line x1="12" y1="11" x2="12" y2="15"/>
<circle cx="12" cy="17.6" r="0.6" fill="currentColor" stroke="none"/>
</svg>
<span class="lbl">다중 경고</span>
<span class="dot" aria-hidden="true"></span>
</button>
</div> </div>
<div class="global-slider"> <div class="global-slider">
<span>유량 배율</span> <span>유량 배율</span>
@@ -103,6 +124,15 @@
</main> </main>
<!-- 다중 경고 시 우측 알람 리스트 dock (severity 별 정렬) -->
<aside class="alarm-list-dock" id="alarm-list-dock">
<header>
<span>활성 알람</span>
<span class="dock-count" id="alarm-list-count">0</span>
</header>
<div id="alarm-list-rows"></div>
</aside>
<!-- ============== EMERGENCY SCENARIO OVERLAY ============== --> <!-- ============== EMERGENCY SCENARIO OVERLAY ============== -->
<!-- 회사 맵 패널 (우측 별도, 평상시 화면 밖) --> <!-- 회사 맵 패널 (우측 별도, 평상시 화면 밖) -->
<aside class="map-panel" id="map-panel"> <aside class="map-panel" id="map-panel">
@@ -194,8 +224,8 @@
<!-- 담당자 이동 경로 (방재센터 → 오·폐수처리장, 평상시 숨김) --> <!-- 담당자 이동 경로 (방재센터 → 오·폐수처리장, 평상시 숨김) -->
<path id="map-route" d="M 857 110 L 500 110 L 110 110" fill="none" stroke="#ff8a3a" stroke-width="2" stroke-dasharray="6 4" opacity="0"/> <path id="map-route" d="M 857 110 L 500 110 L 110 110" fill="none" stroke="#ff8a3a" stroke-width="2" stroke-dasharray="6 4" opacity="0"/>
<!-- 알람 위치 (오·폐수처리장 , 평상시 숨김) --> <!-- 알람 위치 (오·폐수처리장 박스 안 가운데, 평상시 숨김) -->
<g id="map-alarm-pin" transform="translate(110 81)" opacity="0"> <g id="map-alarm-pin" transform="translate(110 95)" opacity="0">
<circle r="22" fill="#ff4f9a" opacity="0.25"/> <circle r="22" fill="#ff4f9a" opacity="0.25"/>
<circle r="14" fill="#ff4f9a" opacity="0.5"/> <circle r="14" fill="#ff4f9a" opacity="0.5"/>
<circle r="7" fill="#ff4f9a"/> <circle r="7" fill="#ff4f9a"/>
@@ -283,6 +313,10 @@
<div class="cctv-target-box"> <div class="cctv-target-box">
<span class="cctv-target-label">TARGET: BW-A1</span> <span class="cctv-target-label">TARGET: BW-A1</span>
</div> </div>
<div class="cctv-alert-icon" title="과압 알람">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4M12 17h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
<span>P-IN-HH</span>
</div>
</div> </div>
</div> </div>
<div class="cctv-meta"> <div class="cctv-meta">
@@ -348,6 +382,7 @@
<script src="js/scenario.js"></script> <script src="js/scenario.js"></script>
<script src="js/ui.js"></script> <script src="js/ui.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
<script src="js/voice.js"></script>
</body> </body>
</html> </html>
+244 -15
View File
@@ -258,6 +258,27 @@
} }
}); });
// 다중 경고 시연 — CRITICAL 시나리오(모달+맵 풀) + HIGH/MEDIUM 핀 + dock
bind('btn-multi-emergency', () => {
if (global.INVYONE_SCENARIO.state.running || window.__multiActive) {
closeScenario();
global.INVYONE_SCENARIO.stop();
stopMultiAlarm();
window.__multiActive = false;
global.INVYONE_ENGINE.emitEvent('[다중 경고] 시연 중단', 'warn');
return;
}
window.__multiActive = true;
// CRITICAL 시나리오 (모달 + 맵 + 배너 + ⚠ 핀) 그대로 시작
global.INVYONE_SCENARIO.play('bw-a1-overpressure');
// CRITICAL 발령 직후 (~1700ms) 추가 알람 발생
setTimeout(() => {
if (!window.__multiActive) return;
startMultiAlarm();
global.INVYONE_ENGINE.emitEvent('[다중 경고] HIGH/MEDIUM 알람 추가 발생 — 우측 알람 리스트 확인', 'alarm');
}, 1700);
});
// 모달 푸터 // 모달 푸터
bind('modal-skip', () => { closeScenario(); global.INVYONE_SCENARIO.stop(); global.INVYONE_ENGINE.emitEvent('시연 스킵', 'info'); }); bind('modal-skip', () => { closeScenario(); global.INVYONE_SCENARIO.stop(); global.INVYONE_ENGINE.emitEvent('시연 스킵', 'info'); });
bind('modal-close', () => { closeScenario(); global.INVYONE_SCENARIO.stop(); }); bind('modal-close', () => { closeScenario(); global.INVYONE_SCENARIO.stop(); });
@@ -283,6 +304,8 @@
qid('alarm-banner-msg').textContent = `${s.targetComp.toUpperCase()} 토출 압력 — 정상범위(4.0~5.0 bar) 초과 / ${s.alarm.title.split('—').pop().trim() || '누설 의심'}`; qid('alarm-banner-msg').textContent = `${s.targetComp.toUpperCase()} 토출 압력 — 정상범위(4.0~5.0 bar) 초과 / ${s.alarm.title.split('—').pop().trim() || '누설 의심'}`;
qid('alarm-banner-loc-text').textContent = `펌프룸 A · ${s.targetComp.toUpperCase()} (원수 취수펌프 #1)`; qid('alarm-banner-loc-text').textContent = `펌프룸 A · ${s.targetComp.toUpperCase()} (원수 취수펌프 #1)`;
banner.classList.add('show'); banner.classList.add('show');
// 전체 화면 CRITICAL 이펙트 (vignette + hazard stripe)
document.body.classList.add('critical-alarm');
}); });
document.addEventListener('scenario:pinShow', e => { document.addEventListener('scenario:pinShow', e => {
@@ -359,6 +382,8 @@
if (modal) modal.classList.add('with-map'); if (modal) modal.classList.add('with-map');
const banner = qid('alarm-banner'); const banner = qid('alarm-banner');
if (banner) banner.classList.add('with-map'); if (banner) banner.classList.add('with-map');
// 전체 화면 critical vignette 도 우측 540px 빼고 그려지게 (맵 영역에 안 새어들어감)
document.body.classList.add('map-open');
const pin = qid('map-alarm-pin'); const pin = qid('map-alarm-pin');
const route = qid('map-route'); const route = qid('map-route');
if (pin) pin.classList.add('active'); if (pin) pin.classList.add('active');
@@ -372,7 +397,7 @@
if (off) { if (off) {
off.classList.add('moving'); off.classList.add('moving');
// 관제실(160,140) → 펌프룸 A(180,345) // 관제실(160,140) → 펌프룸 A(180,345)
off.setAttribute('transform', 'translate(110 81)'); off.setAttribute('transform', 'translate(110 95)');
} }
const stat = qid('modal-officer-status'); const stat = qid('modal-officer-status');
if (stat) { stat.textContent = '🏃 현장 이동 중'; stat.className = 'officer-status responding'; } if (stat) { stat.textContent = '🏃 현장 이동 중'; stat.className = 'officer-status responding'; }
@@ -451,43 +476,247 @@
}); });
// 메인 P&ID 엔진 재개 // 메인 P&ID 엔진 재개
try { global.INVYONE_ENGINE.state.paused = false; } catch {} try { global.INVYONE_ENGINE.state.paused = false; } catch {}
// 전체 화면 CRITICAL 이펙트 + 맵 open 상태 해제
document.body.classList.remove('critical-alarm');
document.body.classList.remove('map-open');
const pin = qid('map-alarm-pin'); if (pin) pin.classList.remove('active'); const pin = qid('map-alarm-pin'); if (pin) pin.classList.remove('active');
const route = qid('map-route'); if (route) route.classList.remove('active'); const route = qid('map-route'); if (route) route.classList.remove('active');
const target = document.querySelector(`.map-building[data-bid="pump-room-a"]`); const target = document.querySelector(`.map-building[data-bid="pump-room-a"]`);
if (target) target.classList.remove('alarm-target'); if (target) target.classList.remove('alarm-target');
qid('map-officer-status').textContent = '김영수 책임자 — 관제실 대기'; qid('map-officer-status').textContent = '김영수 책임자 — 관제실 대기';
stopCctvClock(); stopCctvClock();
// 다중 경고 시연이었으면 추가 핀 + dock 도 같이 정리
if (window.__multiActive) {
stopMultiAlarm();
window.__multiActive = false;
}
} }
// P&ID 안에 BW-A1 위로 알람 핀 SVG 그룹 동적 부착/제거 // P&ID 안에 알람 핀 SVG 그룹 동적 부착/제거 (severity: critical | high | medium)
function showPidAlarmPin(compId) { // CRITICAL 은 가장 크고 강조, HIGH/MEDIUM 은 보조 사이즈
const ports = global.INVYONE_TOPO.computePorts(); function showPidAlarmPin(compId, severity) {
const topPort = ports[`${compId}.top`] || ports[`${compId}.center`]; severity = severity || 'critical';
if (!topPort) return; const palette = {
critical: { core: '#dc1c2e', accent: '#ffd400', text: '#ffd400', label: 'CRITICAL',
ringR: 34, coreR: 24, iconSize: 30, labelW: 116, labelH: 22, labelFont: 13, lift: 50, rectStroke: 3 },
high: { core: '#ff8a3a', accent: '#fff', text: '#fff', label: 'HIGH',
ringR: 22, coreR: 16, iconSize: 20, labelW: 70, labelH: 14, labelFont: 9, lift: 32, rectStroke: 2 },
medium: { core: '#ffd400', accent: '#000', text: '#000', label: 'MEDIUM',
ringR: 16, coreR: 12, iconSize: 14, labelW: 56, labelH: 12, labelFont: 8, lift: 26, rectStroke: 1.5 },
};
const c = palette[severity] || palette.critical;
const ns = 'http://www.w3.org/2000/svg'; const ns = 'http://www.w3.org/2000/svg';
const sceneContent = document.getElementById('scene-content'); const sceneContent = document.getElementById('scene-content');
if (!sceneContent) return; if (!sceneContent) return;
const compEl = document.querySelector(`[data-comp-id="${compId}"]`);
if (!compEl) { console.warn('[scenario] compEl not found for', compId); return; }
// outer g 의 transform="translate(tx ty)" 직접 parse — anchor 시스템이 박은 절대 위치
const tAttr = compEl.getAttribute('transform') || '';
const m = tAttr.match(/translate\(\s*([\-\d.]+)[\s,]+([\-\d.]+)\s*\)/);
if (!m) { console.warn('[scenario] no translate on', compId, 'transform=', tAttr); return; }
const tx = parseFloat(m[1]);
const ty = parseFloat(m[2]);
// pump SVG 의 inner content 는 (0,0) 기준 — components.js 의 COMP.pump 참조
// 펌프 박스 60×60 (rect x=0 y=0 w=60 h=60), 라벨은 y=76. 박스 top center = (30, 0)
const localCx = 30;
const localCy = 0;
const localW = 60;
const localH = 60;
// 절대 좌표 (sceneContent 좌표계)
const cx = tx + localCx;
const cy = ty + localCy;
console.log('[scenario] pin pos', { compId, tx, ty, cx, cy });
// 펌프 외곽 두 겹 — outer 두꺼운 빨강 + inner 노란 hazard 점선
let rectEl = document.getElementById('scenario-rect-' + compId);
if (!rectEl) {
rectEl = document.createElementNS(ns, 'rect');
rectEl.setAttribute('id', 'scenario-rect-' + compId);
rectEl.setAttribute('class', 'scenario-alarm-rect');
sceneContent.appendChild(rectEl);
}
rectEl.setAttribute('x', tx - 6);
rectEl.setAttribute('y', ty - 6);
rectEl.setAttribute('width', localW + 12);
rectEl.setAttribute('height', localH + 12);
rectEl.setAttribute('fill', 'none');
rectEl.setAttribute('stroke', c.core);
rectEl.setAttribute('stroke-width', String(c.rectStroke));
rectEl.setAttribute('rx', '2');
let rectInner = document.getElementById('scenario-rect-inner-' + compId);
if (!rectInner) {
rectInner = document.createElementNS(ns, 'rect');
rectInner.setAttribute('id', 'scenario-rect-inner-' + compId);
rectInner.setAttribute('class', 'scenario-alarm-rect-inner');
sceneContent.appendChild(rectInner);
}
rectInner.setAttribute('x', tx - 3);
rectInner.setAttribute('y', ty - 3);
rectInner.setAttribute('width', localW + 6);
rectInner.setAttribute('height', localH + 6);
rectInner.setAttribute('fill', 'none');
rectInner.setAttribute('stroke', '#ffd400');
rectInner.setAttribute('stroke-width', '1.2');
rectInner.setAttribute('stroke-dasharray', '4 3');
rectInner.setAttribute('rx', '2');
// ⚠ CRITICAL 핀 — 산업 워닝 표지 스타일 (빨강 + 노란 ⚠)
let g = document.getElementById('scenario-pin-' + compId); let g = document.getElementById('scenario-pin-' + compId);
if (!g) { if (!g) {
g = document.createElementNS(ns, 'g'); g = document.createElementNS(ns, 'g');
g.setAttribute('id', 'scenario-pin-' + compId); g.setAttribute('id', 'scenario-pin-' + compId);
g.setAttribute('class', 'scenario-alarm-pin');
sceneContent.appendChild(g); sceneContent.appendChild(g);
} }
g.setAttribute('transform', `translate(${topPort.x} ${topPort.y - 56})`); g.setAttribute('class', `scenario-alarm-pin severity-${severity}`);
g.setAttribute('transform', `translate(${cx} ${cy - c.lift})`);
const labelY = -(c.coreR + 8);
const labelTextY = labelY + c.labelH - 6;
const iconDy = c.iconSize / 3;
g.innerHTML = ` g.innerHTML = `
<circle class="pin-ring-1" r="22" fill="#ff4f9a" opacity="0.55"/> <circle class="pin-ring-1" r="${c.ringR}" fill="${c.core}" opacity="0.5"/>
<circle class="pin-ring-2" r="22" fill="#ff4f9a" opacity="0.55"/> <circle class="pin-ring-2" r="${c.ringR}" fill="${c.core}" opacity="0.5"/>
<circle class="pin-core" r="18" fill="#ff4f9a"/> <circle class="pin-core" r="${c.coreR}" fill="${c.core}"/>
<circle r="18" fill="none" stroke="#fff" stroke-width="2"/> <circle r="${c.coreR}" fill="none" stroke="${c.accent}" stroke-width="${severity === 'critical' ? 2.5 : 1.5}"/>
<text class="pin-core" dy="6" text-anchor="middle" font-size="20" font-weight="700" fill="#fff" pointer-events="none">⚠</text> <text dy="${iconDy}" text-anchor="middle" font-size="${c.iconSize}" font-weight="900" fill="${c.text}" pointer-events="none">⚠</text>
<rect x="-46" y="-44" width="92" height="16" fill="#ff4f9a" stroke="#fff" stroke-width="0.8" pointer-events="none"/> <rect x="${-c.labelW/2}" y="${labelY - c.labelH}" width="${c.labelW}" height="${c.labelH}" fill="${c.core}" stroke="${c.accent}" stroke-width="1" pointer-events="none"/>
<text y="-32" text-anchor="middle" font-size="10" font-weight="800" fill="#fff" pointer-events="none">ALARM HERE</text> <text y="${labelY - 5}" text-anchor="middle" font-size="${c.labelFont}" font-weight="900" fill="${c.text}" letter-spacing="1" pointer-events="none">${c.label}</text>
`; `;
} }
function hidePidAlarmPin(compId) { function hidePidAlarmPin(compId) {
const g = document.getElementById('scenario-pin-' + compId); const g = document.getElementById('scenario-pin-' + compId);
if (g) g.remove(); if (g) g.remove();
const r = document.getElementById('scenario-rect-' + compId);
if (r) r.remove();
const ri = document.getElementById('scenario-rect-inner-' + compId);
if (ri) ri.remove();
}
// ============================================================
// 다중 경고 시연 — severity 별 동시 알람
// CRITICAL 은 메인 emergency-modal 이 처리, HIGH/MEDIUM 은 dock 의 mini-modal 카드로 표시
// ============================================================
const MULTI_ALARMS = [
{
comp: 'bw-a1',
severity: 'critical',
code: 'P-IN-HH',
title: 'BW-A1 펌프 과압 / 누설 의심',
loc: '펌프룸 A · BW-A1 (원수 취수펌프 #1)',
msg: 'BW-A1 토출 압력 7.2bar — 누설 의심',
detail: 'BW-A1 토출 압력이 정상 운전 범위(4.0~5.0 bar)를 초과했습니다. 현장 점검이 즉시 필요합니다.',
},
{
comp: 'uf-pump-a',
severity: 'high',
code: 'UF-FLOW',
title: 'UF 펌프 A 유량 저하 / 막힘 의심',
loc: 'UF 시스템 · UF Pump A',
msg: 'UF 펌프 A 유량 저하 — 막힘 의심',
detail: 'UF 펌프 A 토출 유량이 정상 범위 이하로 떨어졌습니다. 흡입측 막힘 또는 임펠러 이상 가능성 — 현장 점검 필요.',
},
{
comp: 'ro-feed',
severity: 'medium',
code: 'RO-PRESS',
title: 'RO 공급 압력 임계 근접',
loc: 'RO 라인 · RO Feed Pump',
msg: 'RO 공급 압력 임계 근접',
detail: 'RO 멤브레인 공급 압력이 운전 임계점에 근접하고 있습니다. 추세 모니터링 및 사전 점검 권장.',
},
];
function startMultiAlarm() {
MULTI_ALARMS.forEach(a => showPidAlarmPin(a.comp, a.severity));
const dock = document.getElementById('alarm-list-dock');
const rows = document.getElementById('alarm-list-rows');
const count = document.getElementById('alarm-list-count');
if (!dock || !rows || !count) return;
// 다중 모드일 때 메인 모달을 좌측 정렬 + 압축 크기로 고정 → dock 이 항상 그 아래 정렬
const backdrop = document.getElementById('emergency-modal');
if (backdrop) backdrop.classList.add('with-multi');
// CRITICAL 은 메인 모달이 이미 표시 → dock 에는 HIGH/MEDIUM 만 mini-modal 로
const dockAlarms = MULTI_ALARMS.filter(a => a.severity !== 'critical');
count.textContent = String(dockAlarms.length);
const order = { high: 0, medium: 1 };
const sorted = [...dockAlarms].sort((a, b) => order[a.severity] - order[b.severity]);
const now = new Date();
const time = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
rows.innerHTML = sorted.map(a => `
<div class="alarm-mini-modal severity-${a.severity}" data-comp="${a.comp}">
<header class="mini-head">
<div class="mini-head-left">
<span class="mini-severity severity-${a.severity}">${a.severity.toUpperCase()}</span>
<span class="mini-code">${a.code}</span>
</div>
<div class="mini-title">${a.title}</div>
<button class="mini-close" type="button" data-action="close" title="닫기">×</button>
</header>
<div class="mini-location">
<span class="mini-loc-icon">📍</span>
<span class="mini-loc-text">위치: <b>${a.loc}</b></span>
<span class="mini-loc-time">${time}</span>
</div>
<div class="mini-message">
<div class="mini-msg-label">▲ 알람 상세</div>
<div class="mini-msg-text">${a.detail}</div>
</div>
<footer class="mini-foot">
<button class="mini-btn mini-btn-skip" type="button" data-action="close">⏭ 무시</button>
<button class="mini-btn mini-btn-ack" type="button" data-action="ack">✓ 확인 (ACK)</button>
</footer>
</div>
`).join('');
rows.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', () => {
const card = btn.closest('.alarm-mini-modal');
if (!card) return;
dismissOneAlarm(card.dataset.comp, btn.dataset.action);
});
});
dock.classList.add('show');
}
function dismissOneAlarm(comp, action) {
hidePidAlarmPin(comp);
const rows = document.getElementById('alarm-list-rows');
if (!rows) return;
const card = rows.querySelector(`[data-comp="${comp}"]`);
if (card) card.remove();
const remaining = rows.querySelectorAll('.alarm-mini-modal').length;
const count = document.getElementById('alarm-list-count');
if (count) count.textContent = String(remaining);
if (remaining === 0) {
const dock = document.getElementById('alarm-list-dock');
if (dock) dock.classList.remove('show');
}
const a = MULTI_ALARMS.find(x => x.comp === comp);
if (a && global.INVYONE_ENGINE?.emitEvent) {
const tag = action === 'ack' ? '확인(ACK)' : '무시';
const lvl = action === 'ack' ? 'info' : 'warn';
global.INVYONE_ENGINE.emitEvent(`[${a.severity.toUpperCase()}] ${a.code} ${tag}${a.msg}`, lvl);
}
}
function stopMultiAlarm() {
MULTI_ALARMS.forEach(a => hidePidAlarmPin(a.comp));
const rows = document.getElementById('alarm-list-rows');
const dock = document.getElementById('alarm-list-dock');
if (rows) rows.innerHTML = '';
if (dock) dock.classList.remove('show');
const backdrop = document.getElementById('emergency-modal');
if (backdrop) backdrop.classList.remove('with-multi');
} }
let cctvClockTimer = null; let cctvClockTimer = null;
+187
View File
@@ -0,0 +1,187 @@
// INVYONE Stage-2 — 음성 컨트롤
// 시연자 발화 (한국어) → 키워드 매칭 → INVYONE_UI.select(id, type)
// Chrome/Edge 의 Web Speech API (webkitSpeechRecognition) 사용.
// HTTPS 또는 localhost 환경에서만 마이크 권한 허용 — 운영(siflex.invyone.com)은 OK.
(function (global) {
const SR = global.SpeechRecognition || global.webkitSpeechRecognition;
// 시연자 발화 → 컴포넌트 매핑 사전.
// 한 발화 안에 키워드 하나라도 포함되면 매칭. 위 항목이 우선.
const VOICE_MAP = [
// 핵심 1번 설비 (= BW-A1, 원수 취수펌프 #1) — PT 가장 자주 호출
{ id: 'bw-a1', type: 'pump', kws: ['1번', '일번', '원수 취수', '취수 펌프', 'bw-a1', '비더블유 에이 1', '비더블유에이일'] },
{ id: 'bw-a2', type: 'pump', kws: ['2번', '이번', 'bw-a2', '비더블유 에이 2'] },
// 시스템/모듈
{ id: 'uf-system', type: 'module', kws: ['유에프 시스템', 'uf 시스템'] },
{ id: 'uf-pump-a', type: 'pump', kws: ['유에프 펌프', 'uf 펌프', '유에프', 'uf'] },
{ id: 'ro-system', type: 'module', kws: ['알오 시스템', 'ro 시스템', '역삼투'] },
{ id: 'ro-feed', type: 'pump', kws: ['알오 피드', 'ro feed', '알오', 'ro'] },
{ id: 'mbr-carb-l', type: 'module', kws: ['카본 카트리지', '카트리지', '카본'] },
{ id: 'mbr', type: 'tank', kws: ['엠비알', 'mbr'] },
{ id: 'cip-pump', type: 'pump', kws: ['씨아이피 펌프', 'cip 펌프'] },
{ id: 'cip-tank', type: 'tank', kws: ['씨아이피', 'cip'] },
{ id: 'air-blower', type: 'pump', kws: ['에어 블로어', '에어블로어', '송풍기', '블로어'] },
// 탱크
{ id: 'sand', type: 'tank', kws: ['샌드', '모래', 'sand'] },
{ id: 'ac', type: 'tank', kws: ['활성탄', '에이씨', 'ac'] },
{ id: 'raw', type: 'tank', kws: ['원수 탱크', 'raw'] },
{ id: 'chem', type: 'tank', kws: ['약품 탱크', '케미컬', '약품', 'chem'] },
{ id: 'water-filter', type: 'tank', kws: ['정수', '워터 필터', '워터필터', 'water filter'] },
{ id: 'dp', type: 'tank', kws: ['디피', 'dp'] },
{ id: 'regulating', type: 'tank', kws: ['조정 탱크', '레귤레이팅', '조정'] },
// 센서
{ id: 'do', type: 'sensor', kws: ['디오', '용존 산소', '용존산소', 'do'] },
{ id: 'temp', type: 'sensor', kws: ['온도', '템프', 'temp'] },
{ id: 'tss', type: 'sensor', kws: ['티에스에스', '부유물', 'tss'] },
{ id: 'ph', type: 'sensor', kws: ['피에이치', 'ph'] },
];
// ============================================================
// 매칭 엔진
// ============================================================
function normalize(s) {
return (s || '').toString().toLowerCase().replace(/\s+/g, ' ').trim();
}
function match(transcript) {
const t = normalize(transcript);
if (!t) return null;
for (const entry of VOICE_MAP) {
for (const kw of entry.kws) {
if (t.includes(normalize(kw))) {
return { id: entry.id, type: entry.type, matchedKeyword: kw };
}
}
}
return null;
}
// ============================================================
// SpeechRecognition 래퍼
// ============================================================
let recog = null;
let listening = false;
function start() {
if (!SR) {
emitEvent('🎤 이 브라우저는 음성 인식 미지원 (Chrome/Edge 권장)', 'warn');
return;
}
if (listening) return;
recog = new SR();
recog.lang = 'ko-KR';
recog.continuous = true;
recog.interimResults = true;
recog.maxAlternatives = 1;
recog.onstart = () => { listening = true; setBtn(true); setTranscript('듣는 중...'); };
recog.onend = () => { listening = false; setBtn(false); setTranscript(''); };
recog.onerror = (ev) => emitEvent(`🎤 음성 에러: ${ev.error}`, 'warn');
recog.onresult = (ev) => {
let interim = '', finalText = '';
for (let i = ev.resultIndex; i < ev.results.length; i++) {
const r = ev.results[i];
if (r.isFinal) finalText += r[0].transcript;
else interim += r[0].transcript;
}
setTranscript(finalText || interim);
if (finalText) handleFinal(finalText);
};
try { recog.start(); }
catch (e) { emitEvent(`🎤 시작 실패: ${e.message}`, 'warn'); }
}
function stop() { if (recog && listening) recog.stop(); }
function toggle() { listening ? stop() : start(); }
function handleFinal(text) {
const m = match(text);
if (!m) {
emitEvent(`🎤 "${text.trim()}" — 매칭 실패`, 'warn');
return;
}
emitEvent(`🎤 "${text.trim()}" → ${m.id} (${m.type}) [${m.matchedKeyword}]`, 'info');
if (global.INVYONE_UI && global.INVYONE_UI.select) {
global.INVYONE_UI.select(m.id, m.type);
}
flashTarget(m.id);
}
function flashTarget(compId) {
const el = document.querySelector(`[data-comp-id="${compId}"]`);
if (!el) return;
el.classList.remove('voice-flash');
void el.offsetWidth; // reflow
el.classList.add('voice-flash');
setTimeout(() => el.classList.remove('voice-flash'), 1400);
}
function emitEvent(msg, level) {
if (global.INVYONE_ENGINE && global.INVYONE_ENGINE.emitEvent) {
global.INVYONE_ENGINE.emitEvent(msg, level || 'info');
} else {
console.log('[VOICE]', msg);
}
}
// ============================================================
// UI inject (사이드바 첫 번째 section 끝에 마이크 버튼 + transcript)
// ============================================================
function injectUi() {
const sidebar = document.querySelector('.sidebar-section');
if (!sidebar) return;
if (document.getElementById('btn-voice')) return; // 중복 방지
const wrap = document.createElement('div');
wrap.className = 'voice-wrap';
wrap.innerHTML = `
<button id="btn-voice" class="voice-btn" title="음성 인식 켜기/끄기 (한국어)">
<svg class="voice-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="12" rx="3"/>
<path d="M5 11a7 7 0 0 0 14 0"/>
<line x1="12" y1="18" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
<span class="voice-label">VOICE CONTROL</span>
<span class="voice-status-dot" aria-hidden="true"></span>
</button>
<div id="voice-transcript" class="voice-transcript"></div>
`;
sidebar.appendChild(wrap);
document.getElementById('btn-voice').addEventListener('click', toggle);
}
function setBtn(on) {
const btn = document.getElementById('btn-voice');
if (!btn) return;
btn.classList.toggle('listening', on);
const lbl = btn.querySelector('.voice-label');
if (lbl) lbl.textContent = on ? 'LISTENING...' : 'VOICE CONTROL';
}
function setTranscript(text) {
const el = document.getElementById('voice-transcript');
if (el) el.textContent = text || '';
}
function init() {
injectUi();
if (!SR) {
const btn = document.getElementById('btn-voice');
if (btn) btn.disabled = true;
setTranscript('미지원 브라우저');
}
}
// 외부 노출 (디버그/콘솔용)
global.INVYONE_VOICE = { start, stop, toggle, match, init, VOICE_MAP };
// main.js 가 INVYONE_UI 노출한 후 init
document.addEventListener('DOMContentLoaded', () => setTimeout(init, 150));
})(window);
+15 -1
View File
@@ -1,6 +1,13 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools } from 'zustand/middleware'; import { devtools } from 'zustand/middleware';
interface DashEditTarget {
id: string;
name: string;
icon: string;
is_personal: boolean;
}
interface DashboardState { interface DashboardState {
dashboards: Record<string, any>[]; dashboards: Record<string, any>[];
activeDashboardId: string | null; activeDashboardId: string | null;
@@ -8,6 +15,8 @@ interface DashboardState {
editMode: boolean; editMode: boolean;
loading: boolean; loading: boolean;
createOpen: boolean; createOpen: boolean;
/** 수정 모달 대상 — null 이면 모달 닫힘. CreateDashboardModal 을 mode='edit' 로 재사용. */
editTarget: DashEditTarget | null;
setDashboards: (dashboards: Record<string, any>[]) => void; setDashboards: (dashboards: Record<string, any>[]) => void;
setActiveDashboard: (id: string | null) => void; setActiveDashboard: (id: string | null) => void;
@@ -23,6 +32,8 @@ interface DashboardState {
removeDashboard: (id: string) => void; removeDashboard: (id: string) => void;
openCreate: () => void; openCreate: () => void;
closeCreate: () => void; closeCreate: () => void;
openEdit: (target: DashEditTarget) => void;
closeEdit: () => void;
libOpen: boolean; libOpen: boolean;
openLib: () => void; openLib: () => void;
closeLib: () => void; closeLib: () => void;
@@ -37,6 +48,7 @@ export const useDashboardStore = create<DashboardState>()(
editMode: false, editMode: false,
loading: false, loading: false,
createOpen: false, createOpen: false,
editTarget: null,
libOpen: false, libOpen: false,
setDashboards: (dashboards) => set({ dashboards }), setDashboards: (dashboards) => set({ dashboards }),
@@ -94,8 +106,10 @@ export const useDashboardStore = create<DashboardState>()(
}; };
}), }),
openCreate: () => set({ createOpen: true }), openCreate: () => set({ createOpen: true, editTarget: null }),
closeCreate: () => set({ createOpen: false }), closeCreate: () => set({ createOpen: false }),
openEdit: (target) => set({ editTarget: target, createOpen: false }),
closeEdit: () => set({ editTarget: null }),
openLib: () => set({ libOpen: true }), openLib: () => set({ libOpen: true }),
closeLib: () => set({ libOpen: false }), closeLib: () => set({ libOpen: false }),
}), }),
+52 -22
View File
@@ -171,7 +171,6 @@
border-color: rgba(var(--v5-primary-rgb),.3); border-color: rgba(var(--v5-primary-rgb),.3);
} }
.dash-canvas.edit-mode .dash-card-head, .dash-canvas.edit-mode .dash-card-head,
.dash-canvas.edit-mode .dash-mini-body,
.dash-canvas.edit-mode .dash-card-body { .dash-canvas.edit-mode .dash-card-body {
cursor: move; cursor: move;
user-select: none; user-select: none;
@@ -188,6 +187,58 @@
border-color: var(--v5-cyan); z-index: 50; border-color: var(--v5-cyan); z-index: 50;
} }
/* 전체화면 — 카드 헤더의 Maximize2 토글 시 viewport 전체로 확장.
부모 .v5-body 가 overflow:hidden 으로 stacking context 를 만들기 때문에
DashboardCard 가 createPortal 로 document.body 에 마운트하여 진짜 viewport 기준 fixed. */
.dash-card.fullscreen {
position: fixed;
inset: 0;
width: auto;
height: auto;
z-index: 1000;
border-radius: 0;
border: none;
box-shadow: 0 0 0 1px var(--v5-border), 0 -4px 24px rgba(0,0,0,.06);
cursor: default !important;
transform-origin: 50% 50%;
animation: dash-card-fs-in .26s cubic-bezier(.16,1,.3,1);
}
.dash-card.fullscreen .dash-resize-handle { display: none !important; }
.dash-card.fullscreen .dash-card-head {
animation: dash-card-fs-head-in .35s cubic-bezier(.16,1,.3,1) both;
animation-delay: .04s;
}
.dash-card.fullscreen .dash-card-body {
animation: dash-card-fs-body-in .4s cubic-bezier(.16,1,.3,1) both;
animation-delay: .08s;
}
@keyframes dash-card-fs-in {
from { opacity: 0; transform: scale(.96); }
to { opacity: 1; transform: scale(1); }
}
@keyframes dash-card-fs-head-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes dash-card-fs-body-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Exit 애니메이션 — closing flag 가 220ms 동안 유지되는 동안 fade-out + scale-down 후 unmount */
.dash-card.fullscreen.closing {
animation: dash-card-fs-out .22s cubic-bezier(.4,0,1,1) forwards;
}
.dash-card.fullscreen.closing .dash-card-head,
.dash-card.fullscreen.closing .dash-card-body {
animation: none;
}
@keyframes dash-card-fs-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(.96); }
}
/* 카드 헤더 */ /* 카드 헤더 */
.dash-card-head { .dash-card-head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
@@ -370,27 +421,6 @@
.dash-snap-guide.v { width: 2px; } .dash-snap-guide.v { width: 2px; }
.dash-snap-guide.h { height: 2px; } .dash-snap-guide.h { height: 2px; }
/* 접힌 카드 */
.dash-card.collapsed .dash-card-body { display: none; }
.dash-card.collapsed .dash-mini-body { display: flex; flex-direction: column;
flex: 1; overflow: hidden; padding: .65rem .8rem; gap: .5rem; }
.dash-card:not(.collapsed) .dash-mini-body { display: none; }
/* 미니 본문 통계 */
.dash-mini-stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: .45rem; flex: 1; align-content: start;
}
.dash-mini-stat {
padding: .55rem .65rem; border-radius: 9px; background: var(--v5-bg-subtle);
border: 1px solid var(--v5-border); display: flex; flex-direction: column;
justify-content: center; min-height: 54px;
}
.dash-mini-stat .ms-label { font-size: .5rem; font-weight: 600;
color: var(--v5-text-muted); text-transform: uppercase; letter-spacing: .06em; }
.dash-mini-stat .ms-value { font-size: 1.15rem; font-weight: 800;
color: var(--v5-text); margin-top: .15rem; letter-spacing: -.02em; line-height: 1; }
/* ── 빈 대시보드 ── */ /* ── 빈 대시보드 ── */
.dash-empty { .dash-empty {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
+7 -5
View File
@@ -558,20 +558,22 @@
/* ================================================================= /* =================================================================
Tweaks floating panel (디자인시스템 Tweaks UX). Tweaks floating panel (디자인시스템 Tweaks UX).
우하단 240px 고정, 슬라이드 인/아웃. SettingsModal 이 사용. 헤더 우측 SlidersHorizontal 버튼 anchor popover (top:56px right:14px).
헤더 높이 50px + 6px gap. SettingsModal 이 사용.
================================================================= */ ================================================================= */
.v5-tweaks-panel{ .v5-tweaks-panel{
position:fixed;right:14px;bottom:14px; position:fixed;top:56px;right:14px;
width:240px;z-index:200; width:240px;z-index:200;
background:var(--v5-surface-solid); background:var(--v5-surface-solid);
border:1px solid var(--v5-border); border:1px solid var(--v5-border);
border-radius:var(--v5-radius-lg-2); border-radius:var(--v5-radius-lg-2);
padding:var(--v5-sp-4); padding:var(--v5-sp-4);
font-family:var(--v5-font-sans); font-family:var(--v5-font-sans);
opacity:0;transform:translateY(10px) scale(.97);pointer-events:none; box-shadow:0 8px 24px rgba(0,0,0,.08);
opacity:0;transform:translateY(-6px) scale(.97);pointer-events:none;
transition: transition:
opacity .25s var(--v5-ease-enter), opacity .2s var(--v5-ease-enter),
transform .3s var(--v5-ease-enter); transform .25s var(--v5-ease-enter);
} }
.v5-tweaks-panel.on{ .v5-tweaks-panel.on{
opacity:1;transform:translateY(0) scale(1);pointer-events:auto; opacity:1;transform:translateY(0) scale(1);pointer-events:auto;
@@ -0,0 +1,457 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>SCADA Component Library</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px;
background: #050a18;
color: #fff;
font-family: 'Segoe UI', sans-serif;
}
h1 {
font-size: 18px; margin: 0 0 4px;
color: #5af; letter-spacing: 1px;
}
.subtitle {
font-size: 12px; color: #8aa; margin-bottom: 20px;
}
.lib {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.card {
background: #0a1428;
border: 1px solid #1e3060;
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
}
.card h3 {
font-size: 13px;
margin: 0 0 8px;
color: #5af;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.badge {
font-size: 9px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 600;
letter-spacing: 0.5px;
}
.badge.ok { background: #1a5a2a; color: #7cff3a; border: 1px solid #2a8b3a; }
.badge.fail { background: #5a1a1a; color: #ff5a5a; border: 1px solid #8b2a2a; }
.stage {
background: #050a18;
border: 1px solid #1e3060;
padding: 12px;
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
border-radius: 4px;
}
.meta {
margin-top: 8px;
font-size: 10px;
color: #8aa;
font-family: 'Consolas', monospace;
border-top: 1px dashed #1e3060;
padding-top: 6px;
}
.meta .key { color: #5af; }
.meta div { margin: 2px 0; word-break: break-all; }
.ctrl {
margin-top: 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.ctrl button {
background: #0a1830;
border: 1px solid #1e3060;
color: #cfd3d8;
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
border-radius: 3px;
font-family: inherit;
}
.ctrl button.active {
background: #1a5a2a;
border-color: #5af04a;
color: #fff;
}
.ctrl button:hover { filter: brightness(1.3); }
.empty-card {
background: #0a1428;
border: 1px dashed #1e3060;
border-radius: 6px;
padding: 24px;
color: #4a6a8a;
text-align: center;
font-size: 12px;
}
</style>
</head>
<body>
<h1>SCADA Component Library</h1>
<div class="subtitle">검증 통과한 SVG 컴포넌트 카탈로그 — 인스턴스 prefix 적용 전 디자인/동작 확인용</div>
<div class="lib" id="lib"></div>
<script>
//==============================================================
// 컴포넌트 등록 — 검증 통과한 SVG 만 추가
// 다음 컴포넌트 받으면 LIBRARY 배열에 push
//==============================================================
const LIBRARY = [
{
id: 'pipe-straight',
title: '#18 Pipe Straight (horizontal)',
viewBox: '0 0 100 20',
displayWidth: 240,
displayHeight: 48,
requiredIds: ['pipe-outer', 'pipe-inner', 'pipe-flow'],
notes: 'CSS rotate(90deg) 로 수직 파이프 재사용 가능',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20" overflow="visible" role="img" aria-label="SCADA straight pipe" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-active:#7cff3a;--scada-warning:#ff8a3a;--scada-alarm:#ff4f9a;--scada-idle:#2a3f5a">
<defs>
<linearGradient id="pipe-metal-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d9d9d9"/>
<stop offset="0.18" stop-color="#f4f4f4"/>
<stop offset="0.5" stop-color="#8f8f8f"/>
<stop offset="0.82" stop-color="#d7d7d7"/>
<stop offset="1" stop-color="#6f6f6f"/>
</linearGradient>
</defs>
<style>
.scada-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
.scada-strong{stroke:var(--scada-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
.scada-fill-idle{fill:var(--scada-idle)}
.scada-fill-metal{fill:url(#pipe-metal-grad)}
.flow-line{stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
</style>
<rect x="2" y="4" width="96" height="12" rx="2" ry="2" id="pipe-outer" class="scada-fill-metal scada-stroke"/>
<rect x="6" y="7" width="88" height="6" rx="1.5" ry="1.5" id="pipe-inner" class="scada-fill-idle scada-stroke"/>
<line x1="8" y1="5" x2="8" y2="15" class="scada-strong"/>
<line x1="12" y1="5" x2="12" y2="15" class="scada-stroke"/>
<line x1="88" y1="5" x2="88" y2="15" class="scada-stroke"/>
<line x1="92" y1="5" x2="92" y2="15" class="scada-strong"/>
<line x1="12" y1="10" x2="88" y2="10" id="pipe-flow" class="flow-line"/>
</svg>`,
},
{
id: 'pipe-elbow',
title: '#19 Pipe Elbow 90° (┐ base)',
viewBox: '0 0 50 50',
displayWidth: 120,
displayHeight: 120,
requiredIds: ['pipe-outer', 'pipe-inner', 'pipe-flow'],
notes: 'CSS rotate(90/180/270) 로 ┘/└/┌ 재사용. stroke 방식이라 곡선 깔끔',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" overflow="visible" role="img" aria-label="SCADA pipe elbow 90 degrees" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-active:#7cff3a;--scada-warning:#ff8a3a;--scada-alarm:#ff4f9a;--scada-idle:#2a3f5a">
<defs>
<linearGradient id="pipe-metal-grad-19" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d9d9d9"/>
<stop offset="0.18" stop-color="#f4f4f4"/>
<stop offset="0.5" stop-color="#8f8f8f"/>
<stop offset="0.82" stop-color="#d7d7d7"/>
<stop offset="1" stop-color="#6f6f6f"/>
</linearGradient>
</defs>
<style>
.e19-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
.e19-outer{fill:none;stroke:url(#pipe-metal-grad-19);stroke-width:12;stroke-linecap:butt;stroke-linejoin:round;vector-effect:non-scaling-stroke}
.e19-inner{fill:none;stroke:var(--scada-idle);stroke-width:6;stroke-linecap:butt;stroke-linejoin:round;vector-effect:non-scaling-stroke}
.e19-flow{fill:none;stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
</style>
<path id="pipe-outer" class="e19-outer e19-stroke" d="M2 14 H24 A12 12 0 0 1 36 26 V48"/>
<path id="pipe-inner" class="e19-inner e19-stroke" d="M2 14 H24 A12 12 0 0 1 36 26 V48"/>
<path id="pipe-flow" class="e19-flow" d="M4 14 H24 A12 12 0 0 1 36 26 V46"/>
</svg>`,
},
{
id: 'pipe-tjunction',
title: '#20 Pipe T-junction (┬ base)',
viewBox: '0 0 60 40',
displayWidth: 180,
displayHeight: 120,
requiredIds: ['pipe-main-outer', 'pipe-main-inner', 'pipe-main-flow', 'pipe-branch-outer', 'pipe-branch-inner', 'pipe-branch-flow'],
notes: '한 덩어리 path + 좌/우/아래 평평한 단면 + flange 3쌍. branch-outer/inner 는 hidden helper (실 외곽은 main path 가 T자 전체 그림). CSS rotate 로 ┤/┴/├',
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40" overflow="visible" role="img" aria-label="SCADA pipe T-junction" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-active:#7cff3a;--scada-warning:#ff8a3a;--scada-alarm:#ff4f9a;--scada-idle:#2a3f5a">
<defs>
<linearGradient id="pipe-metal-grad-20" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d9d9d9"/>
<stop offset="0.18" stop-color="#f4f4f4"/>
<stop offset="0.5" stop-color="#8f8f8f"/>
<stop offset="0.82" stop-color="#d7d7d7"/>
<stop offset="1" stop-color="#6f6f6f"/>
</linearGradient>
</defs>
<style>
.t20-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
.t20-strong{stroke:var(--scada-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
.t20-metal{fill:url(#pipe-metal-grad-20)}
.t20-idle{fill:var(--scada-idle)}
.t20-flow{fill:none;stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
.t20-hidden{fill:none;stroke:none}
</style>
<path id="pipe-main-outer" d="M2 4H58V16H38Q36 16 36 18V38H24V18Q24 16 22 16H2Z" class="t20-metal t20-stroke"/>
<path id="pipe-main-inner" d="M6 7H54V13H34Q33 13 33 14V34H27V14Q27 13 26 13H6Z" class="t20-idle t20-stroke"/>
<path id="pipe-branch-outer" d="M24 16H36V38H24Z" class="t20-hidden"/>
<path id="pipe-branch-inner" d="M27 14H33V34H27Z" class="t20-hidden"/>
<line x1="8" y1="5" x2="8" y2="15" class="t20-strong"/>
<line x1="12" y1="5" x2="12" y2="15" class="t20-stroke"/>
<line x1="48" y1="5" x2="48" y2="15" class="t20-stroke"/>
<line x1="52" y1="5" x2="52" y2="15" class="t20-strong"/>
<line x1="25" y1="28" x2="35" y2="28" class="t20-stroke"/>
<line x1="25" y1="32" x2="35" y2="32" class="t20-strong"/>
<line id="pipe-main-flow" x1="12" y1="10" x2="48" y2="10" class="t20-flow"/>
<line id="pipe-branch-flow" x1="30" y1="16" x2="30" y2="32" class="t20-flow"/>
</svg>`,
},
{
id: 'pipe-dynamic',
title: '#18b Pipe Straight — Dynamic Length Demo',
viewBox: '동적 (length 슬라이더로 가변)',
displayWidth: null,
displayHeight: null,
requiredIds: ['pipe-outer', 'pipe-inner', 'pipe-flow'],
notes: 'JS 가 length px 받아서 양 끝 flange 고정 + 가운데만 늘어남. 흐름량 슬라이더로 흐르는 속도(L/min) 도 가변',
dynamic: 'length',
minLength: 80,
maxLength: 800,
initialLength: 300,
},
{ id: 'placeholder-11', title: '#11 Centrifugal Pump', placeholder: true },
{ id: 'placeholder-14', title: '#14 Gate Valve', placeholder: true },
];
//==============================================================
// 동적 SVG 생성 — pipe straight 의 어떤 길이도 자동 생성
// 양 끝 flange 좌표는 절대값, 가운데 outer/inner/flow 만 length 따라 늘어남
//==============================================================
function createPipeStraightSVG(lengthPx) {
const W = lengthPx;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 20" width="${W}" height="20" overflow="visible" role="img" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-idle:#2a3f5a">
<defs>
<linearGradient id="pipe-metal-grad-dyn" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d9d9d9"/>
<stop offset="0.18" stop-color="#f4f4f4"/>
<stop offset="0.5" stop-color="#8f8f8f"/>
<stop offset="0.82" stop-color="#d7d7d7"/>
<stop offset="1" stop-color="#6f6f6f"/>
</linearGradient>
</defs>
<style>
.pdyn-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
.pdyn-strong{stroke:var(--scada-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
.pdyn-metal{fill:url(#pipe-metal-grad-dyn)}
.pdyn-idle{fill:var(--scada-idle)}
.pdyn-flow{stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
</style>
<rect id="pipe-outer" x="2" y="4" width="${W - 4}" height="12" rx="2" ry="2" class="pdyn-metal pdyn-stroke"/>
<rect id="pipe-inner" x="6" y="7" width="${W - 12}" height="6" rx="1.5" ry="1.5" class="pdyn-idle pdyn-stroke"/>
<line x1="8" y1="5" x2="8" y2="15" class="pdyn-strong"/>
<line x1="12" y1="5" x2="12" y2="15" class="pdyn-stroke"/>
<line x1="${W - 12}" y1="5" x2="${W - 12}" y2="15" class="pdyn-stroke"/>
<line x1="${W - 8}" y1="5" x2="${W - 8}" y2="15" class="pdyn-strong"/>
<line id="pipe-flow" x1="12" y1="10" x2="${W - 12}" y2="10" class="pdyn-flow"/>
</svg>`;
}
//==============================================================
// 카드 렌더
//==============================================================
const STATE_COLORS = {
active: '#7cff3a',
warning: '#ff8a3a',
alarm: '#ff4f9a',
idle: '#2a3f5a',
};
const lib = document.getElementById('lib');
LIBRARY.forEach(comp => {
if (comp.placeholder) {
const ph = document.createElement('div');
ph.className = 'empty-card';
ph.textContent = '⌛ ' + comp.title + ' — 대기 중';
lib.appendChild(ph);
return;
}
const card = document.createElement('div');
card.className = 'card';
const header = document.createElement('h3');
header.innerHTML = `<span>${comp.title}</span>`;
card.appendChild(header);
const stage = document.createElement('div');
stage.className = 'stage';
card.appendChild(stage);
const meta = document.createElement('div');
meta.className = 'meta';
card.appendChild(meta);
const ctrl = document.createElement('div');
ctrl.className = 'ctrl';
card.appendChild(ctrl);
const flowCtrl = document.createElement('div');
flowCtrl.style.marginTop = '8px';
card.appendChild(flowCtrl);
let lengthCtrl = null;
if (comp.dynamic === 'length') {
lengthCtrl = document.createElement('div');
lengthCtrl.style.marginTop = '6px';
card.appendChild(lengthCtrl);
}
card._flowActive = true;
card._flowRate = 50;
card._flowOffset = 0;
let currentRotation = 0;
function paintSvg(svgHtml) {
stage.innerHTML = svgHtml;
const svgEl = stage.querySelector('svg');
if (comp.displayWidth) svgEl.setAttribute('width', comp.displayWidth);
if (comp.displayHeight) svgEl.setAttribute('height', comp.displayHeight);
if (currentRotation) svgEl.style.transform = `rotate(${currentRotation}deg)`;
// 검증
const allIds = Array.from(svgEl.querySelectorAll('[id]')).map(el => el.id);
const missing = comp.requiredIds.filter(id => !allIds.includes(id));
const ok = missing.length === 0;
const oldBadge = header.querySelector('.badge');
if (oldBadge) oldBadge.remove();
const badge = document.createElement('span');
badge.className = 'badge ' + (ok ? 'ok' : 'fail');
badge.textContent = ok ? '✓ VERIFIED' : '✗ MISSING ' + missing.length;
header.appendChild(badge);
meta.innerHTML = `
<div><span class="key">viewBox</span>: ${svgEl.getAttribute('viewBox') || comp.viewBox}</div>
<div><span class="key">IDs</span> (${allIds.length}): ${allIds.join(', ')}</div>
${comp.notes ? `<div><span class="key">notes</span>: ${comp.notes}</div>` : ''}
`;
return svgEl;
}
// 초기 SVG
let svgEl = comp.dynamic === 'length'
? paintSvg(createPipeStraightSVG(comp.initialLength))
: paintSvg(comp.svg);
const hasFlow = svgEl.querySelectorAll('[id$="-flow"], [id="pipe-flow"]').length > 0;
if (hasFlow) {
// 상태 토글
['active', 'warning', 'alarm', 'idle'].forEach(state => {
const btn = document.createElement('button');
btn.dataset.state = state;
btn.textContent = state;
btn.addEventListener('click', () => {
ctrl.querySelectorAll('button[data-state]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const cur = stage.querySelector('svg');
cur.style.setProperty('--scada-flow', STATE_COLORS[state]);
card._flowActive = (state !== 'idle');
});
ctrl.appendChild(btn);
});
// 회전 버튼
[0, 90, 180, 270].forEach(deg => {
const btn = document.createElement('button');
btn.textContent = deg + '°';
if (deg === 0) btn.style.marginLeft = '12px';
btn.addEventListener('click', () => {
currentRotation = deg;
const cur = stage.querySelector('svg');
cur.style.transform = `rotate(${deg}deg)`;
});
ctrl.appendChild(btn);
});
// flow rate 슬라이더 (유량)
flowCtrl.innerHTML = `
<label style="display:flex;justify-content:space-between;font-size:10px;color:#8aa;font-family:Consolas">
<span>유량 (Flow rate)</span>
<span><b data-out>50</b> L/min</span>
</label>
<input type="range" min="0" max="100" value="50" step="1" style="width:100%;margin-top:2px">
`;
const flowSlider = flowCtrl.querySelector('input');
const flowOut = flowCtrl.querySelector('[data-out]');
flowSlider.addEventListener('input', () => {
card._flowRate = +flowSlider.value;
flowOut.textContent = card._flowRate;
card._flowActive = card._flowRate > 0;
flowOut.style.color = card._flowRate === 0 ? '#666' : card._flowRate > 70 ? '#7cff3a' : '#5af9ff';
});
// 기본: active
ctrl.querySelector('[data-state="active"]').click();
// 애니메이션 — flowRate 가 속도 결정 (0=정지, 100=최대)
setInterval(() => {
if (!card._flowActive) return;
const speed = card._flowRate / 100 * 1.8;
card._flowOffset -= speed;
const cur = stage.querySelector('svg');
if (cur) {
cur.querySelectorAll('[id$="-flow"], [id="pipe-flow"]').forEach(el => {
el.setAttribute('stroke-dashoffset', card._flowOffset);
});
}
}, 30);
}
// dynamic length: 슬라이더로 SVG 재생성
if (comp.dynamic === 'length' && lengthCtrl) {
lengthCtrl.innerHTML = `
<label style="display:flex;justify-content:space-between;font-size:10px;color:#8aa;font-family:Consolas">
<span>파이프 길이</span>
<span><b data-out>${comp.initialLength}</b> px</span>
</label>
<input type="range" min="${comp.minLength}" max="${comp.maxLength}" value="${comp.initialLength}" step="10" style="width:100%;margin-top:2px">
`;
const lenSlider = lengthCtrl.querySelector('input');
const lenOut = lengthCtrl.querySelector('[data-out]');
lenSlider.addEventListener('input', () => {
const len = +lenSlider.value;
lenOut.textContent = len;
paintSvg(createPipeStraightSVG(len));
// 현재 활성 상태 색 다시 입히기
const activeBtn = ctrl.querySelector('button[data-state].active');
if (activeBtn) {
const state = activeBtn.dataset.state;
const cur = stage.querySelector('svg');
cur.style.setProperty('--scada-flow', STATE_COLORS[state]);
}
});
}
lib.appendChild(card);
});
</script>
</body>
</html>
@@ -0,0 +1,555 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>AUTO CHEMICAL SUPPLY MONITORING WEB SYSTEM</title>
<style>
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; background: #050a18; color: #fff;
font-family: 'Segoe UI', sans-serif;
}
.header {
background: linear-gradient(180deg, #1a3870 0%, #0c1f4a 100%);
padding: 8px 20px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 1px solid #1e4080;
}
.header h1 { font-size: 18px; margin: 0; letter-spacing: 2px; font-weight: 700; }
.header .clock { font-size: 14px; font-family: 'Consolas', monospace; }
.main {
display: grid; grid-template-columns: 1fr 380px; gap: 12px;
padding: 12px;
}
.tanks-area {
display: grid; grid-template-columns: repeat(4, 1fr) 1.4fr;
grid-template-rows: 1fr 1fr;
gap: 12px;
}
.tank-cell {
background: #050a18;
border: 1px solid #1e3060;
padding: 6px;
display: flex; flex-direction: column; align-items: center;
border-radius: 4px;
}
.tank-cell .label {
font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #fff;
}
.hno3-group {
grid-column: 5 / 6; grid-row: 1 / 2;
background: #050a18;
border: 1px dashed #5a8;
padding: 8px;
display: flex; flex-direction: column;
}
.hno3-group .group-title {
text-align: center; font-size: 13px; font-weight: 600;
color: #5af; margin-bottom: 4px;
}
.hno3-group .group-tanks {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px;
flex: 1;
}
.hno3-group .tank-cell { border: none; padding: 2px; }
.h2so4-cell { grid-column: 1 / 3; grid-row: 2 / 3; }
.h2o2-cell { grid-column: 3 / 5; grid-row: 2 / 3; }
.gauge-row {
margin-top: 4px;
background: #0a1830; border: 1px solid #1a2f50;
padding: 4px 6px; width: 100%;
display: flex; flex-direction: column; align-items: center;
}
.gauge-row .gauge-label {
font-size: 9px; color: #8aa; margin-bottom: 2px;
}
.gauge-bars {
display: flex; gap: 2px; height: 10px; width: 100%;
}
.gauge-bars > div {
flex: 1; transition: background 0.3s;
}
.right-panel {
display: flex; flex-direction: column; gap: 8px;
}
.panel-box {
border: 1px solid #2a4070;
background: #0a1830;
border-radius: 4px;
}
.panel-box .panel-title {
background: linear-gradient(180deg, #1a3870, #0c1f4a);
color: #5af; text-align: center; padding: 4px;
font-size: 12px; font-weight: 600;
}
.panel-grid {
display: grid; gap: 4px; padding: 6px;
}
.panel-grid > div {
padding: 6px 4px; text-align: center; font-size: 11px; font-weight: 600;
border-radius: 3px; cursor: pointer; transition: filter 0.2s;
}
.panel-grid > div:hover { filter: brightness(1.3); }
.grid-5 { grid-template-columns: repeat(5, 1fr); }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.cell-green { background: #2a8b3a; color: #fff; }
.cell-pink { background: #d04880; color: #fff; }
.data-table {
border: 1px solid #2a4070;
background: #050a18;
border-radius: 4px;
overflow: hidden;
}
.data-table .panel-title {
background: linear-gradient(180deg, #1a3870, #0c1f4a);
color: #5af; text-align: center; padding: 4px;
font-size: 12px; font-weight: 600;
}
.data-table table {
width: 100%; border-collapse: collapse; font-size: 10px;
font-family: 'Consolas', monospace;
}
.data-table th, .data-table td {
border: 1px solid #1a2f50;
padding: 2px 4px; text-align: center;
}
.data-table thead { background: #0c1f4a; }
.data-table .group-th {
background: #142850; color: #5af; font-weight: 600;
}
.data-table tbody tr.fresh { animation: flash 1s ease-out; }
@keyframes flash {
0% { background: rgba(90, 255, 200, 0.4); }
100% { background: transparent; }
}
.footer {
background: #1a3a1a; color: #5af04a;
padding: 4px 20px; font-size: 12px; font-weight: 600;
border-top: 1px solid #1e6020;
}
/* 뚜껑 — 우측 끝 (89, 35) 을 경첩으로 -90도 회전 */
.tank-lid {
transform-origin: 89px 35px;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.tank-lid:hover { filter: brightness(1.15); }
.tank-lid.open { transform: rotate(90deg); }
.tank-cell { overflow: visible; }
</style>
</head>
<body>
<div class="header">
<h1>AUTO CHEMICAL SUPPLY MONITORING WEB SYSTEM</h1>
<div class="clock" id="clock">--:--:-- --/--/----</div>
</div>
<div class="main">
<!-- LEFT: 탱크 영역 -->
<div class="tanks-area" id="tanksArea">
<div class="tank-cell" id="cell-HCL"></div>
<div class="tank-cell" id="cell-CuCl2"></div>
<div class="tank-cell" id="cell-OXA"></div>
<!-- 4번째 자리는 비고 (HNO3 그룹이 5열 1행에 들어감) -->
<div></div>
<div class="hno3-group">
<div class="group-title">HNO3 - AU PLATING</div>
<div class="group-tanks">
<div class="tank-cell" id="cell-HNO3"></div>
<div class="tank-cell" id="cell-AU"></div>
<div class="tank-cell" id="cell-WHNO3"></div>
</div>
</div>
<div class="tank-cell h2so4-cell" id="cell-H2SO4"></div>
<div class="tank-cell h2o2-cell" id="cell-H2O2"></div>
</div>
<!-- RIGHT: 패널 + 테이블 -->
<div class="right-panel">
<div class="panel-box">
<div class="panel-title">SUPPLY CHEMICAL TO STORAGE TANK</div>
<div class="panel-grid grid-5">
<div class="cell-green">HCL</div>
<div class="cell-green">OXA</div>
<div class="cell-green">HNO3</div>
<div class="cell-green">H2O2</div>
<div class="cell-green">H2SO4</div>
</div>
</div>
<div class="panel-box">
<div class="panel-title">WASTE CHEMICAL</div>
<div class="panel-grid grid-2">
<div class="cell-green">CuCl2</div>
<div class="cell-green">HNO3 WASTE</div>
</div>
</div>
<div class="panel-box">
<div class="panel-title">SUPPLY CHEMICAL TO PRODUCTION</div>
<div class="panel-grid grid-3">
<div class="cell-green">CuCl2 DES#1</div>
<div class="cell-green">HCL DES#1</div>
<div class="cell-green">HCL CF2</div>
<div class="cell-green">OXA DES#1</div>
<div class="cell-pink">HNO3 AU</div>
<div class="cell-pink">H2SO4 COPPER</div>
</div>
</div>
<div class="data-table">
<div class="panel-title">SUPPLY AMOUNT OF COPPER PLATING ROOM</div>
<table>
<thead>
<tr>
<th rowspan="2">TIME</th>
<th colspan="2" class="group-th">HCL (L)</th>
<th colspan="2" class="group-th">CuCl2 (L)</th>
<th colspan="2" class="group-th">OXA (L)</th>
<th colspan="2" class="group-th">HNO3 (L)</th>
</tr>
<tr>
<th>FLOW</th><th>TOTAL</th>
<th>FLOW</th><th>TOTAL</th>
<th>FLOW</th><th>TOTAL</th>
<th>FLOW</th><th>TOTAL</th>
</tr>
</thead>
<tbody id="dataBody"></tbody>
</table>
</div>
</div>
</div>
<div class="footer">● System connected normally</div>
<script>
//==============================================================
// SVG 템플릿 (ChatGPT GPT-5 가 생성한 산업용 탱크)
// — id 와 url(#) 은 인스턴스마다 prefix 치환됨
//==============================================================
const TANK_SVG_TEMPLATE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 180" overflow="visible" role="img" aria-label="Industrial chemical storage tank">
<defs>
<linearGradient id="metal-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="var(--metal-dark)"/>
<stop offset="0.18" stop-color="var(--metal-light)"/>
<stop offset="0.42" stop-color="#ffffff"/>
<stop offset="0.72" stop-color="var(--metal-dark)"/>
<stop offset="1" stop-color="var(--metal-light)"/>
</linearGradient>
<linearGradient id="glass-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.18"/>
<stop offset="0.12" stop-color="#ffffff" stop-opacity="0.04"/>
<stop offset="0.5" stop-color="#0b1528" stop-opacity="0.35"/>
<stop offset="0.88" stop-color="#ffffff" stop-opacity="0.06"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.16"/>
</linearGradient>
<clipPath id="tank-clip">
<rect x="31" y="40" width="56" height="113"/>
</clipPath>
</defs>
<style>
.tank-num{fill:#cfd3d8;font-family:Arial,Helvetica,sans-serif;font-size:7px;text-anchor:end}
.tank-mono{fill:#ffffff;font-family:Consolas,'Courier New',monospace;font-size:13px;text-anchor:middle;dominant-baseline:middle}
</style>
<rect x="0" y="0" width="110" height="180" fill="#050a18"/>
<!-- Scale ruler -->
<g>
<line x1="22" y1="40" x2="22" y2="155" stroke="#ffffff" stroke-width="1" vector-effect="non-scaling-stroke"/>
<g stroke="#ffffff" stroke-width="1" vector-effect="non-scaling-stroke">
<line x1="17" y1="155" x2="22" y2="155"/>
<line x1="18.5" y1="149.25" x2="22" y2="149.25"/>
<line x1="18.5" y1="143.5" x2="22" y2="143.5"/>
<line x1="18.5" y1="137.75" x2="22" y2="137.75"/>
<line x1="17" y1="132" x2="22" y2="132"/>
<line x1="18.5" y1="126.25" x2="22" y2="126.25"/>
<line x1="18.5" y1="120.5" x2="22" y2="120.5"/>
<line x1="18.5" y1="114.75" x2="22" y2="114.75"/>
<line x1="17" y1="109" x2="22" y2="109"/>
<line x1="18.5" y1="103.25" x2="22" y2="103.25"/>
<line x1="18.5" y1="97.5" x2="22" y2="97.5"/>
<line x1="18.5" y1="91.75" x2="22" y2="91.75"/>
<line x1="17" y1="86" x2="22" y2="86"/>
<line x1="18.5" y1="80.25" x2="22" y2="80.25"/>
<line x1="18.5" y1="74.5" x2="22" y2="74.5"/>
<line x1="18.5" y1="68.75" x2="22" y2="68.75"/>
<line x1="17" y1="63" x2="22" y2="63"/>
<line x1="18.5" y1="57.25" x2="22" y2="57.25"/>
<line x1="18.5" y1="51.5" x2="22" y2="51.5"/>
<line x1="18.5" y1="45.75" x2="22" y2="45.75"/>
<line x1="17" y1="40" x2="22" y2="40"/>
</g>
<text x="15" y="157" class="tank-num">0</text>
<text x="15" y="134" class="tank-num">20</text>
<text x="15" y="111" class="tank-num">40</text>
<text x="15" y="88" class="tank-num">60</text>
<text x="15" y="65" class="tank-num">80</text>
<text x="15" y="42" class="tank-num">100</text>
</g>
<!-- 뚜껑 (클릭하면 -90도 회전: ㅡ → ㅣ) -->
<g class="tank-lid">
<!-- 클릭 영역을 살짝 넓혀주는 투명 hit-box -->
<rect x="25" y="8" width="68" height="32" fill="transparent"/>
<!-- Top nozzle -->
<rect x="51" y="10" width="16" height="4" rx="1" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<rect x="54" y="14" width="10" height="6" fill="url(#metal-grad)" stroke="var(--tank-stroke)" stroke-width="1"/>
<!-- Dome cap -->
<path d="M29 35 C29 22 43 18 59 18 C75 18 89 22 89 35 L89 39 L29 39 Z" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<rect x="27" y="35" width="64" height="5" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<line x1="29" y1="38" x2="89" y2="38" stroke="#333333" stroke-width="0.8"/>
</g>
<!-- Tank glass body -->
<rect x="29" y="39" width="60" height="115" fill="#081326" stroke="#ffffff" stroke-width="1"/>
<rect x="31" y="40" width="56" height="113" fill="url(#glass-grad)" stroke="var(--tank-stroke)" stroke-width="1"/>
<!-- Liquid -->
<rect id="tank-liquid" x="31" y="155" width="56" height="0" fill="var(--liquid-color)" clip-path="url(#tank-clip)"/>
<ellipse id="tank-surface" cx="59" cy="155" rx="28" ry="2.2" fill="var(--liquid-color)" opacity="0.85" clip-path="url(#tank-clip)"/>
<!-- Glass highlight -->
<path d="M35 43 L43 43 L38 146 L34 153 Z" fill="#ffffff" opacity="0.16"/>
<rect x="82" y="42" width="3" height="111" fill="#ffffff" opacity="0.08"/>
<!-- Level readout -->
<rect id="level-bg" x="40" y="83" width="38" height="18" rx="4" fill="#000000" stroke="#ffffff" stroke-width="1"/>
<text id="level-text" x="59" y="92" class="tank-mono">0.0%</text>
<!-- Base -->
<rect x="28" y="153" width="62" height="7" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<line x1="30" y1="154.5" x2="88" y2="154.5" stroke="#ffffff" stroke-width="0.6" opacity="0.65"/>
<!-- Outlet pipe + pump -->
<rect x="55" y="160" width="8" height="12" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<circle cx="59" cy="166" r="8.5" fill="#081326" stroke="#ffffff" stroke-width="1"/>
<circle cx="59" cy="166" r="6.6" fill="none" stroke="var(--tank-stroke)" stroke-width="1"/>
<path d="M56 161.8 L56 170.2 L63.5 166 Z" fill="#ffffff"/>
<rect x="56" y="173" width="7" height="7" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
<rect x="53" y="178" width="13" height="2" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
</svg>`;
//==============================================================
// 탱크 SVG 컴포넌트 — 재사용 가능
//==============================================================
class TankGauge {
/**
* opts:
* id 고유 식별자 (SVG 내부 id 충돌 방지용 prefix)
* label 표시 라벨 (HCL, CuCl2 등)
* capacityText "10m3" 같은 부가 텍스트 (없으면 표시 안함)
* color 액체 색상 (CSS color)
* level 초기 레벨 (0~100)
* width/height SVG 크기 (생략시 110 x 180)
*/
constructor(opts) {
Object.assign(this, opts);
this.level = opts.level ?? 0;
this.element = this._render();
}
_render() {
const wrapper = document.createElement('div');
wrapper.style.cssText = 'display:flex;flex-direction:column;align-items:center;width:100%;';
// 라벨
const labelEl = document.createElement('div');
labelEl.style.cssText = 'font-size:13px;font-weight:600;color:#fff;margin-bottom:2px;';
labelEl.innerHTML = `${this.label}${this.capacityText ? ` <span style="color:#8af;font-size:11px;">(${this.capacityText})</span>` : ''}`;
wrapper.appendChild(labelEl);
// SVG: id 와 url(#) 모두 instance prefix 치환
const id = this.id;
const svgHtml = TANK_SVG_TEMPLATE
.replace(/id="/g, `id="${id}-`)
.replace(/url\(#/g, `url(#${id}-`);
const tmp = document.createElement('div');
tmp.innerHTML = svgHtml;
const svgEl = tmp.firstElementChild;
// 크기 조절 (HNO3 sub 탱크는 작게)
svgEl.setAttribute('width', this.width ?? 110);
svgEl.setAttribute('height', this.height ?? 180);
// CSS 변수로 색상 주입 — 인스턴스별로 액체/금속 색 분리
svgEl.style.setProperty('--liquid-color', this.color);
svgEl.style.setProperty('--tank-stroke', '#aaaaaa');
svgEl.style.setProperty('--metal-light', '#dddddd');
svgEl.style.setProperty('--metal-dark', '#666666');
svgEl.style.background = '#050a18';
svgEl.style.display = 'block';
// 뚜껑 클릭 → open/close 토글
const lid = svgEl.querySelector('.tank-lid');
if (lid) {
lid.addEventListener('click', (e) => {
e.stopPropagation();
lid.classList.toggle('open');
});
}
wrapper.appendChild(svgEl);
// 막대 게이지 (탱크 아래)
const gauge = document.createElement('div');
gauge.className = 'gauge-row';
gauge.innerHTML = `
<div class="gauge-label">${this.label} SUPPLY LINES</div>
<div class="gauge-bars" id="bars-${id}"></div>
`;
wrapper.appendChild(gauge);
return wrapper;
}
/**
* 동적 레벨 변경 — SVG 내부 4개 element 갱신:
* tank-liquid (rect) : y, height
* tank-surface (ellipse): cy
* level-text (text) : textContent
* level-bg (rect) : fill (30% 미만 빨강 / 90% 초과 녹색 / 그 외 검정)
*/
setLevel(pct) {
pct = Math.max(0, Math.min(100, pct));
this.level = pct;
const id = this.id;
const liquid = this.element.querySelector(`#${id}-tank-liquid`);
const surface = this.element.querySelector(`#${id}-tank-surface`);
const pctText = this.element.querySelector(`#${id}-level-text`);
const bg = this.element.querySelector(`#${id}-level-bg`);
if (!liquid) return;
// 받은 SVG 좌표: 액체 영역 y=40 (가득) ~ y=155 (바닥), 높이 115
const fullH = 115;
const baseY = 155;
const h = (pct / 100) * fullH;
const y = baseY - h;
liquid.setAttribute('y', y);
liquid.setAttribute('height', h);
surface.setAttribute('cy', y);
pctText.textContent = pct.toFixed(1) + '%';
if (pct < 30) bg.setAttribute('fill', '#a02020');
else if (pct > 90) bg.setAttribute('fill', '#206020');
else bg.setAttribute('fill', '#000000');
}
mount(parentSelector) {
const parent = document.querySelector(parentSelector);
if (parent) parent.appendChild(this.element);
}
}
//==============================================================
// 막대 게이지 — 14칸 (녹/주/꺼짐 랜덤)
//==============================================================
function renderBars(id, count = 14) {
const host = document.getElementById(`bars-${id}`);
if (!host) return;
host.innerHTML = '';
for (let i = 0; i < count; i++) {
const bar = document.createElement('div');
const r = Math.random();
bar.style.background = r < 0.55 ? '#3aa848' : r < 0.85 ? '#e88728' : '#1a2f50';
host.appendChild(bar);
}
}
//==============================================================
// 탱크 6 + 3 = 9 인스턴스 생성
//==============================================================
const tanks = [
new TankGauge({ id: 'HCL', label: 'HCL', capacityText: '10m3', color: '#ff8a3a', level: 56.6 }),
new TankGauge({ id: 'CuCl2', label: 'CuCl2', capacityText: '10m3', color: '#3acc4a', level: 73.7 }),
new TankGauge({ id: 'OXA', label: 'OXA', capacityText: '10m3', color: '#5ac8e8', level: 44.5 }),
new TankGauge({ id: 'HNO3', label: 'HNO3', capacityText: '', color: '#e040a8', level: 42.7, width: 80, height: 130 }),
new TankGauge({ id: 'AU', label: 'AU PLATING', capacityText: '', color: '#888888', level: 0, width: 80, height: 130 }),
new TankGauge({ id: 'WHNO3', label: 'WASTE HNO3', capacityText: '', color: '#f6a8c8', level: 47.6, width: 80, height: 130 }),
new TankGauge({ id: 'H2SO4', label: 'H2SO4', capacityText: '5m3', color: '#e0488a', level: 72.9 }),
new TankGauge({ id: 'H2O2', label: 'H2O2', capacityText: '5m3', color: '#f8c8d8', level: 84.1 }),
];
tanks.forEach(t => {
t.mount(`#cell-${t.id}`);
t.setLevel(t.level);
renderBars(t.id);
});
//==============================================================
// 시뮬레이션 — 1초마다 탱크 레벨 ±0.3% 변동, 막대 2초마다 갱신
//==============================================================
setInterval(() => {
tanks.forEach(t => {
if (t.id === 'AU') return; // AU PLATING 은 빈 탱크 유지
const delta = (Math.random() - 0.5) * 0.6;
t.setLevel(t.level + delta);
});
}, 1000);
setInterval(() => {
tanks.forEach(t => renderBars(t.id));
}, 2000);
//==============================================================
// 시계
//==============================================================
function tick() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
const date = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
document.getElementById('clock').textContent = `${time} ${date}`;
}
tick();
setInterval(tick, 1000);
//==============================================================
// 데이터 테이블 시뮬레이션 — 8행 유지, 5초마다 새 행 추가
//==============================================================
const tbody = document.getElementById('dataBody');
const totals = { HCL: 1258.4, CuCl2: 1523.1, OXA: 1024.8, HNO3: 856.3 };
function addRow() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const t = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const flows = {
HCL: 18 + Math.random() * 1.5,
CuCl2: 22 + Math.random() * 1.0,
OXA: 15 + Math.random() * 0.8,
HNO3: 12.5 + Math.random() * 0.6,
};
Object.keys(flows).forEach(k => totals[k] += flows[k]);
const tr = document.createElement('tr');
tr.classList.add('fresh');
tr.innerHTML = `
<td>${t}</td>
<td>${flows.HCL.toFixed(1)}</td><td>${totals.HCL.toFixed(1)}</td>
<td>${flows.CuCl2.toFixed(1)}</td><td>${totals.CuCl2.toFixed(1)}</td>
<td>${flows.OXA.toFixed(1)}</td><td>${totals.OXA.toFixed(1)}</td>
<td>${flows.HNO3.toFixed(1)}</td><td>${totals.HNO3.toFixed(1)}</td>
`;
tbody.insertBefore(tr, tbody.firstChild);
while (tbody.children.length > 8) tbody.removeChild(tbody.lastChild);
}
for (let i = 0; i < 8; i++) addRow();
setInterval(addRow, 5000);
</script>
</body>
</html>