0911-noon
This commit is contained in:
parent
29e674a81b
commit
11f4fec721
273
src/App.vue
273
src/App.vue
@ -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>
|
||||
|
||||
BIN
src/circuit-ele/sliding_rheostat.png
Normal file
BIN
src/circuit-ele/sliding_rheostat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
BIN
src/circuit-ele/sliding_rheostat_pin.png
Normal file
BIN
src/circuit-ele/sliding_rheostat_pin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
8
src/components/PalettePanel.vue
Normal file
8
src/components/PalettePanel.vue
Normal 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
19
src/utils.ts
Normal 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}`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user