0911-noon

This commit is contained in:
feie9454 2025-09-11 19:22:18 +08:00
parent 29e674a81b
commit 11f4fec721
5 changed files with 164 additions and 136 deletions

View File

@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref, watchEffect } from 'vue'
import AIAssistant from './components/AIAssistant.vue'
import { elements, type CircuitElement, type Preset } from './elements'
import { formatValue } from './utils'
// 1)
type PaletteItem = CircuitElement
@ -105,8 +106,6 @@ function zoomAt(clientX: number, clientY: number, deltaY: number) {
const DND_MIME = 'application/x-circuit-element'
function onPaletteDragStart(e: DragEvent, item: PaletteItem) {
console.log(e);
if (!e.dataTransfer) return
e.dataTransfer.setData(DND_MIME, item.key)
e.dataTransfer.setData('text/plain', item.key)
@ -125,13 +124,10 @@ function onCanvasDrop(e: DragEvent) {
const item = palette.find(p => p.key === key)
if (!item) return
const { x, y } = clientToWorld(e.clientX, e.clientY)
//
const gx = Math.round(x / state.gridSize) * state.gridSize
const gy = Math.round(y / state.gridSize) * state.gridSize
const defSize = item.defaultSize ?? 64
const props: Record<string, number | string> = {}
; (item.propertySchemas || []).forEach((p) => { props[p.key] = p.default })
const inst: Instance = { id: String(++idSeq), key: item.key, url: item.url, x: gx, y: gy, size: defSize, rotation: 0, props }
const inst: Instance = { id: String(++idSeq), key: item.key, url: item.url, x: x, y: y, size: defSize, rotation: 0, props }
if (item.stateImages) inst.state = 'off'
instances.push(inst)
}
@ -507,7 +503,7 @@ function solveMNA() {
})
// null
//
const CURRENT_ON_THRESHOLD = 0.1 // A 100 mA
for (const inst of instances) {
@ -564,25 +560,7 @@ function gaussianSolve(A: number[][], b: number[]): number[] | null {
//
watchEffect(() => { solveMNA() })
function formatValue(v: number | null, unit: string) {
if (v == null || !Number.isFinite(v)) return '—'
//
const absv = Math.abs(v)
const units = [
{ s: 1e-9, u: 'n' },
{ s: 1e-6, u: 'µ' },
{ s: 1e-3, u: 'm' },
{ s: 1, u: '' },
{ s: 1e3, u: 'k' },
{ s: 1e6, u: 'M' },
]
let u = ''
let val = v
for (let i = units.length - 1; i >= 0; i--) {
if (absv >= units[i].s) { u = units[i].u; val = v / units[i].s; break }
}
return `${val.toFixed(3)} ${u}${unit}`
}
function meterText(inst: Instance) {
const live = instLive[inst.id]
@ -593,7 +571,7 @@ function meterText(inst: Instance) {
try {
// "(i)=>..." "(i,v,props)=>..."
// 使
const fn = new Function('i','r','props', 'return (' + code + ')(i, r, props)')
const fn = new Function('i', 'r', 'props', 'return (' + code + ')(i, r, props)')
const out = fn(i, r, inst.props)
return String(out)
} catch {
@ -747,7 +725,6 @@ function loadFromJSONText(text: string) {
//
const world = obj.world || {}
const rawInstances = Array.isArray(obj.instances) ? obj.instances : []
const rawWires = Array.isArray(obj.wires) ? obj.wires : []
//
const knownKeys = new Set(elements.map(e => e.key))
@ -770,64 +747,46 @@ function loadFromJSONText(text: string) {
props: rawProps,
state: r.state === 'on' || r.state === 'off' ? r.state : undefined,
}
// props便线
;(inst as any).__savedConnections = savedConnections
// props便线
; (inst as any).__savedConnections = savedConnections
validInstances.push(inst)
}
// 线线
const instMap = new Map(validInstances.map(i => [i.id, i]))
const validWires: Wire[] = []
if (rawWires.length > 0) {
// wires
for (const w of rawWires) {
if (!w || typeof w !== 'object') continue
const a = w.a, b = w.b
if (!a || !b) continue
const ia = instMap.get(String(a.instId))
const ib = instMap.get(String(b.instId))
if (!ia || !ib) continue
const elemA = elements.find(e => e.key === ia.key)
const elemB = elements.find(e => e.key === ib.key)
const cpsA = elemA?.connectionPoints?.length ?? 0
const cpsB = elemB?.connectionPoints?.length ?? 0
const cpa = safeNumber(a.cpIndex)
const cpb = safeNumber(b.cpIndex)
if (cpa < 0 || cpa >= cpsA || cpb < 0 || cpb >= cpsB) continue
validWires.push({ id: String(w.id ?? (++wireSeq)), a: { instId: String(a.instId), cpIndex: cpa }, b: { instId: String(b.instId), cpIndex: cpb } })
}
} else {
// props.__connections 线
const dedup = new Set<string>()
const getKey = (a: EndpointRef, b: EndpointRef) => {
const k1 = `${a.instId}:${a.cpIndex}`
const k2 = `${b.instId}:${b.cpIndex}`
return k1 < k2 ? `${k1}|${k2}` : `${k2}|${k1}`
}
for (const inst of validInstances) {
const conns: SaveConnections | undefined = (inst as any).__savedConnections
if (!conns) continue
const elem = elements.find(e => e.key === inst.key)
const cps = elem?.connectionPoints?.length ?? 0
for (const [cpStr, arr] of Object.entries(conns)) {
const cpi = safeNumber(cpStr)
if (cpi < 0 || cpi >= cps) continue
for (const ref of arr || []) {
const tgt = instMap.get(String(ref.instId))
if (!tgt) continue
const elemB = elements.find(e => e.key === tgt.key)
const cpsB = elemB?.connectionPoints?.length ?? 0
const cpb = safeNumber(ref.cpIndex)
if (cpb < 0 || cpb >= cpsB) continue
const a: EndpointRef = { instId: inst.id, cpIndex: cpi }
const b: EndpointRef = { instId: String(ref.instId), cpIndex: cpb }
const key = getKey(a, b)
if (dedup.has(key)) continue
dedup.add(key)
validWires.push({ id: String(++wireSeq), a, b })
}
// props.__connections 线
const dedup = new Set<string>()
const getKey = (a: EndpointRef, b: EndpointRef) => {
const k1 = `${a.instId}:${a.cpIndex}`
const k2 = `${b.instId}:${b.cpIndex}`
return k1 < k2 ? `${k1}|${k2}` : `${k2}|${k1}`
}
for (const inst of validInstances) {
const conns: SaveConnections | undefined = (inst as any).__savedConnections
if (!conns) continue
const elem = elements.find(e => e.key === inst.key)
const cps = elem?.connectionPoints?.length ?? 0
for (const [cpStr, arr] of Object.entries(conns)) {
const cpi = safeNumber(cpStr)
if (cpi < 0 || cpi >= cps) continue
for (const ref of arr || []) {
const tgt = instMap.get(String(ref.instId))
if (!tgt) continue
const elemB = elements.find(e => e.key === tgt.key)
const cpsB = elemB?.connectionPoints?.length ?? 0
const cpb = safeNumber(ref.cpIndex)
if (cpb < 0 || cpb >= cpsB) continue
const a: EndpointRef = { instId: inst.id, cpIndex: cpi }
const b: EndpointRef = { instId: String(ref.instId), cpIndex: cpb }
const key = getKey(a, b)
if (dedup.has(key)) continue
dedup.add(key)
validWires.push({ id: String(++wireSeq), a, b })
}
}
}
//
@ -904,7 +863,7 @@ function deleteSelected() {
<div class="palette">
<div v-for="item in palette" :key="item.key" class="palette-item" draggable="true"
@dragstart="(e) => onPaletteDragStart(e, item)" :title="item.name">
<img :src="item.url" :alt="item.name" draggable="false"/>
<img :src="item.url" :alt="item.name" draggable="false" />
<span class="label">{{ item.name }}</span>
</div>
</div>
@ -913,11 +872,11 @@ function deleteSelected() {
<button class="btn" @click="onExportJSON">导出 JSON</button>
<button class="btn" @click="triggerImport">导入 JSON</button>
<input ref="importInputRef" type="file" accept="application/json,.json" class="hidden-file"
@change="onImportFileChange" />
@change="onImportFileChange" />
</div>
</aside>
<!-- 画布视口可平移缩放接收拖拽 -->
<!-- 画布视口可平移缩放接收拖拽 -->
<main class="viewport" ref="viewportRef" @mousedown="onViewportMouseDown"
@mousemove="(e) => { onViewportMouseMove(e); onViewportMouseMoveDrag(e); onViewportMouseMoveTrack(e) }"
@mouseup="() => { onViewportMouseUp(); onViewportMouseUpDrag() }"
@ -938,30 +897,25 @@ function deleteSelected() {
:y1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).y"
:x2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).x"
:y2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).y">
<stop offset="0%" stop-color="#f3f4f6"/>
<stop offset="100%" stop-color="#6b7280"/>
<stop offset="0%" stop-color="#f3f4f6" />
<stop offset="100%" stop-color="#6b7280" />
</linearGradient>
</template>
</defs>
<g>
<!-- 分层导线底层暗色顶层渐变高光圆角端点更有质感 -->
<g v-for="w in wires" :key="w.id" class="wire-group" filter="url(#wireShadow)"
@click.stop="(e) => removeWire(w.id, e)">
<line class="wire-base"
:x1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).x"
@click.stop="(e) => removeWire(w.id, e)">
<line class="wire-base" :x1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).x"
:y1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).y"
:x2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).x"
:y2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).y" stroke-linecap="round"
vector-effect="non-scaling-stroke" />
<line class="wire-highlight" :x1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).x"
:y1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).y"
:x2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).x"
:y2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).y"
stroke-linecap="round"
vector-effect="non-scaling-stroke" />
<line class="wire-highlight"
:x1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).x"
:y1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).y"
:x2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).x"
:y2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).y"
:stroke="'url(#wireGrad-' + w.id + ')'"
stroke-linecap="round"
vector-effect="non-scaling-stroke" />
:stroke="'url(#wireGrad-' + w.id + ')'" stroke-linecap="round" vector-effect="non-scaling-stroke" />
</g>
<!-- 预览线当选中一个端点时显示到鼠标或吸附端点的线 -->
@ -974,8 +928,9 @@ function deleteSelected() {
<!-- 已放置的元件可拖动 -->
<div v-for="inst in instances" :key="inst.id" class="inst" :class="{ selected: selectedId === inst.id }"
:style="{ left: inst.x + 'px', top: inst.y + 'px', transform: 'translate(-50%, -50%) rotate(' + (inst.rotation || 0) + 'deg)'}" @mousedown="(e) => onInstanceMouseDown(e, inst)"
@click.stop="() => selectInstance(inst.id)" :title="inst.key">
:style="{ left: inst.x + 'px', top: inst.y + 'px', transform: 'translate(-50%, -50%) rotate(' + (inst.rotation || 0) + 'deg)' }"
@mousedown="(e) => onInstanceMouseDown(e, inst)" @click.stop="() => selectInstance(inst.id)"
:title="inst.key">
<div class="inst-box" :style="{ width: inst.size + 'px', height: inst.size + 'px' }">
<img
:src="(elements.find(e => e.key === inst.key)?.stateImages ? (inst.state === 'on' ? elements.find(e => e.key === inst.key)!.stateImages!.on : elements.find(e => e.key === inst.key)!.stateImages!.off) : inst.url)"
@ -984,14 +939,14 @@ function deleteSelected() {
<!-- 灯泡发散光线叠加层 -->
<svg v-if="inst.key === 'light_bulb'" class="bulb-glow" :width="inst.size" :height="inst.size"
:viewBox="'0 0 ' + inst.size + ' ' + inst.size" aria-hidden="true">
:viewBox="'0 0 ' + inst.size + ' ' + inst.size" aria-hidden="true">
<g :opacity="getBulbGlow(inst).opacity">
<line v-for="k in getBulbGlow(inst).count" :key="k"
:x1="(inst.size/2) + Math.cos(2*Math.PI*(k-1)/getBulbGlow(inst).count)*(inst.size*0.42)"
:y1="(inst.size/2) + Math.sin(2*Math.PI*(k-1)/getBulbGlow(inst).count)*(inst.size*0.42)"
:x2="(inst.size/2) + Math.cos(2*Math.PI*(k-1)/getBulbGlow(inst).count)*(inst.size*(0.42 + getBulbGlow(inst).lenFrac))"
:y2="(inst.size/2) + Math.sin(2*Math.PI*(k-1)/getBulbGlow(inst).count)*(inst.size*(0.42 + getBulbGlow(inst).lenFrac))"
stroke="#f59e0b" stroke-width="2" stroke-linecap="round" vector-effect="non-scaling-stroke" />
:x1="(inst.size / 2) + Math.cos(2 * Math.PI * (k - 1) / getBulbGlow(inst).count) * (inst.size * 0.42)"
:y1="(inst.size / 2) + Math.sin(2 * Math.PI * (k - 1) / getBulbGlow(inst).count) * (inst.size * 0.42)"
:x2="(inst.size / 2) + Math.cos(2 * Math.PI * (k - 1) / getBulbGlow(inst).count) * (inst.size * (0.42 + getBulbGlow(inst).lenFrac))"
:y2="(inst.size / 2) + Math.sin(2 * Math.PI * (k - 1) / getBulbGlow(inst).count) * (inst.size * (0.42 + getBulbGlow(inst).lenFrac))"
stroke="#f59e0b" stroke-width="2" stroke-linecap="round" vector-effect="non-scaling-stroke" />
</g>
</svg>
@ -1011,12 +966,12 @@ function deleteSelected() {
hover: isNearest(inst.id, idx),
connected: hoveredEndpoint && isConnectedEndpoint(inst.id, idx)
}" :style="{
left: getConnectionPointWorldPos(inst, idx).x + 'px',
top: getConnectionPointWorldPos(inst, idx).y + 'px'
}" @click.stop="(e) => onEndpointClick(inst.id, idx, e)"
@mouseenter="() => { hoveredEndpoint = { instId: inst.id, cpIndex: idx } }"
@mouseleave="() => { if (hoveredEndpoint && hoveredEndpoint.instId === inst.id && hoveredEndpoint.cpIndex === idx) hoveredEndpoint = null }"
:title="cp.name || ('P' + (idx + 1))"></div>
left: getConnectionPointWorldPos(inst, idx).x + 'px',
top: getConnectionPointWorldPos(inst, idx).y + 'px'
}" @click.stop="(e) => onEndpointClick(inst.id, idx, e)"
@mouseenter="() => { hoveredEndpoint = { instId: inst.id, cpIndex: idx } }"
@mouseleave="() => { if (hoveredEndpoint && hoveredEndpoint.instId === inst.id && hoveredEndpoint.cpIndex === idx) hoveredEndpoint = null }"
:title="cp.name || ('P' + (idx + 1))"></div>
</template>
</div>
</div>
@ -1029,18 +984,15 @@ function deleteSelected() {
<button v-else class="ai-reopen" title="打开 AI 助手" @click="showAI = true"></button>
<!-- 底部属性面板编辑选中元件的业务属性非尺寸 -->
<footer v-if="selectedInst" class="bottom-panel" :style="{ right: showAI ? '360px' : '0' }">
<footer v-if="selectedInst" class="bottom-panel" :style="{ right: showAI ? '360px' : '0' }">
<div class="panel-inner">
<div class="prop-title">{{ selectedInst.key }} 属性</div>
<!-- 预设按钮区域显示当前元件的预设点击一键应用 -->
<div v-if="selectedMeta?.preset?.length" class="presets">
<span class="preset-label">预设</span>
<button
v-for="p in selectedMeta!.preset"
:key="p.name"
class="preset-btn"
@click.stop="applyPreset(p)"
>{{ p.name }}</button>
<button v-for="p in selectedMeta!.preset" :key="p.name" class="preset-btn" @click.stop="applyPreset(p)">{{
p.name
}}</button>
</div>
<div class="props">
<!-- 实时电学量 -->
@ -1058,8 +1010,10 @@ function deleteSelected() {
<input type="range" min="-180" max="180" step="1" v-model.number="selectedInst!.rotation" />
<input type="number" class="deg-input" step="1" v-model.number="selectedInst!.rotation" />
<span class="unit">deg</span>
<button class="rot-btn" @click="selectedInst!.rotation = ((selectedInst!.rotation || 0) - 45 + 360) % 360">-45°</button>
<button class="rot-btn" @click="selectedInst!.rotation = ((selectedInst!.rotation || 0) + 45) % 360">+45°</button>
<button class="rot-btn"
@click="selectedInst!.rotation = ((selectedInst!.rotation || 0) - 45 + 360) % 360">-45°</button>
<button class="rot-btn"
@click="selectedInst!.rotation = ((selectedInst!.rotation || 0) + 45) % 360">+45°</button>
</label>
<template v-for="schema in (elements.find(e => e.key === selectedInst!.key)?.propertySchemas || [])"
:key="schema.key">
@ -1085,7 +1039,8 @@ function deleteSelected() {
<style scoped>
.app {
display: grid;
grid-template-columns: 140px 1fr var(--ai-width, 360px); /* 左栏 / 画布 / AI 辅助 */
grid-template-columns: 140px 1fr var(--ai-width, 360px);
/* 左栏 / 画布 / AI 辅助 */
grid-template-rows: 100vh;
overflow: hidden;
}
@ -1104,6 +1059,7 @@ function deleteSelected() {
gap: 8px;
align-items: center;
}
.file-actions .btn {
padding: 6px 10px;
border: 1px solid #e5e7eb;
@ -1112,8 +1068,14 @@ function deleteSelected() {
color: #111827;
cursor: pointer;
}
.file-actions .btn:hover { background: #f3f4f6; }
.hidden-file { display: none; }
.file-actions .btn:hover {
background: #f3f4f6;
}
.hidden-file {
display: none;
}
.sidebar h2 {
margin: 0 0 8px 0;
@ -1213,9 +1175,12 @@ function deleteSelected() {
background: #fff;
color: #111827;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.ai-reopen:hover {
background: #f3f4f6;
}
.ai-reopen:hover { background: #f3f4f6; }
.world {
position: absolute;
@ -1243,19 +1208,30 @@ function deleteSelected() {
}
/* 新增:分层导线样式 */
.wire-group { cursor: pointer; }
.wire-group {
cursor: pointer;
}
.wire-base {
stroke: #111827;
stroke-width: 3;
opacity: 0.85;
}
.wire-highlight {
stroke-width: 2;
mix-blend-mode: multiply;
}
/* 悬停时微弱高亮 */
.wire-group:hover .wire-base { opacity: 0.95; }
.wire-group:hover .wire-highlight { filter: brightness(1.06); }
.wire-group:hover .wire-base {
opacity: 0.95;
}
.wire-group:hover .wire-highlight {
filter: brightness(1.06);
}
/* 放大可点击区域(不可见)以提升命中,通过 pointer-events 控制组级点击 */
.wire-hit {
stroke: transparent;
@ -1263,7 +1239,11 @@ function deleteSelected() {
}
/* 预览线保留原 class 名,便于最小改动 */
.wire { stroke: #111827; stroke-width: 2; }
.wire {
stroke: #111827;
stroke-width: 2;
}
.wire.preview {
stroke: #3b82f6;
stroke-dasharray: 10 6;
@ -1272,8 +1252,13 @@ function deleteSelected() {
}
@keyframes dash-move {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -32; }
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -32;
}
}
.inst {
@ -1344,8 +1329,10 @@ function deleteSelected() {
/* 连通端点高亮:当某端点悬停时,其所有通过导线连通的端点以强调色显示 */
.endpoint.connected {
border-color: #10b981; /* teal-500 */
background: #ecfdf5; /* teal-50 */
border-color: #10b981;
/* teal-500 */
background: #ecfdf5;
/* teal-50 */
transform: translate(-50%, -50%) scale(1.25);
}
@ -1353,7 +1340,8 @@ function deleteSelected() {
position: fixed;
left: 140px;
/* 与左侧栏对齐 */
right: 360px; /* 默认让出 AI 侧栏宽度,实际由内联样式覆盖 */
right: 360px;
/* 默认让出 AI 侧栏宽度,实际由内联样式覆盖 */
bottom: 0;
background: #f9fafb;
border-top: 1px solid #e5e7eb;
@ -1370,6 +1358,7 @@ function deleteSelected() {
.panel-actions {
margin-left: auto;
}
.danger-btn {
padding: 6px 10px;
border: 1px solid #fecaca;
@ -1379,6 +1368,7 @@ function deleteSelected() {
cursor: pointer;
width: max-content;
}
.danger-btn:hover {
background: #fecaca;
border-color: #ef4444;
@ -1390,10 +1380,12 @@ function deleteSelected() {
align-items: center;
gap: 8px;
}
.preset-label {
color: #4b5563;
font-size: 12px;
}
.preset-btn {
padding: 4px 8px;
border: 1px solid #e5e7eb;
@ -1402,7 +1394,10 @@ function deleteSelected() {
color: #111827;
cursor: pointer;
}
.preset-btn:hover { background: #f3f4f6; }
.preset-btn:hover {
background: #f3f4f6;
}
.bottom-panel .props {
display: flex;
@ -1427,7 +1422,10 @@ function deleteSelected() {
}
/* 旋转控制样式 */
.deg-input { width: 70px; }
.deg-input {
width: 70px;
}
.rot-btn {
padding: 4px 8px;
border: 1px solid #e5e7eb;
@ -1436,5 +1434,8 @@ function deleteSelected() {
color: #111827;
cursor: pointer;
}
.rot-btn:hover { background: #f3f4f6; }
.rot-btn:hover {
background: #f3f4f6;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -0,0 +1,8 @@
<script setup lang="ts">
import { elements, type CircuitElement, type Preset } from '../elements'
// 1)
type PaletteItem = CircuitElement
const palette: PaletteItem[] = [...elements]
</script>

19
src/utils.ts Normal file
View File

@ -0,0 +1,19 @@
export function formatValue(v: number | null, unit: string) {
if (v == null || !Number.isFinite(v)) return '—'
// 简易工程计数法
const absv = Math.abs(v)
const units = [
{ s: 1e-9, u: 'n' },
{ s: 1e-6, u: 'µ' },
{ s: 1e-3, u: 'm' },
{ s: 1, u: '' },
{ s: 1e3, u: 'k' },
{ s: 1e6, u: 'M' },
]
let u = ''
let val = v
for (let i = units.length - 1; i >= 0; i--) {
if (absv >= units[i].s) { u = units[i].u; val = v / units[i].s; break }
}
return `${val.toFixed(3)} ${u}${unit}`
}