diff --git a/app/src/core/service/Settings.tsx b/app/src/core/service/Settings.tsx index af297550..7ebdeb02 100644 --- a/app/src/core/service/Settings.tsx +++ b/app/src/core/service/Settings.tsx @@ -55,6 +55,16 @@ export const settingsSchema = z.object({ compatibilityMode: z.boolean().default(false), isEnableEntityCollision: z.boolean().default(false), isEnableSectionCollision: z.boolean().default(false), + isEnableForceDirected: z.boolean().default(false), + forceDirectedLinkDistance: z.number().min(50).max(500).default(200), + forceDirectedLinkStrength: z.number().min(0.001).max(0.1).multipleOf(0.001).default(0.01), + forceDirectedCollisionStrength: z.number().min(0.01).max(1).multipleOf(0.01).default(0.5), + forceDirectedVelocityDecay: z.number().min(0.1).max(0.99).multipleOf(0.01).default(0.6), + forceDirectedMaxMovePerFrame: z.number().int().min(10).max(200).default(50), + forceDirectedConvergenceThreshold: z.number().min(0.01).max(10).multipleOf(0.01).default(0.5), + forceDirectedMinDistance: z.number().int().min(5).max(100).default(30), + moveToAxis: z.union([z.literal("y"), z.literal("x"), z.literal("both")]).default("y"), + moveToClamp: z.number().int().min(10).max(10000).default(200), autoNamerTemplate: z.string().default("..."), autoNamerSectionTemplate: z.string().default("Section_{{i}}"), autoNamerDetailsTemplate: z.string().default(""), @@ -446,6 +456,8 @@ export const settingsSchema = z.object({ { type: "item", id: "setSelectedImageAsBackground", label: "转化为背景图片", icon: "Images" }, { type: "item", id: "unsetSelectedImageAsBackground", label: "取消背景化", icon: "SquareSquare" }, { type: "item", id: "saveSelectedImagesToProjectDirectory", label: "另存图片到当前prg所在目录下", icon: "Save" }, + { type: "item", id: "wrapImageInCaptionSection", label: "将图片包裹到说明框中", icon: "ImagePlus" }, + { type: "item", id: "toggleForceDirected", label: "开启/关闭力导向", icon: "Network" }, ]), disabledExtensions: z.array(z.string()).default([]), extensionSettings: z.record(z.record(z.unknown())).default({}), diff --git a/app/src/core/service/SettingsIcons.tsx b/app/src/core/service/SettingsIcons.tsx index cf19e8a1..a6a33b47 100644 --- a/app/src/core/service/SettingsIcons.tsx +++ b/app/src/core/service/SettingsIcons.tsx @@ -85,6 +85,7 @@ import { MouseRight, MouseLeft, LoaderPinwheel, + Network, Circle, } from "lucide-react"; @@ -163,6 +164,14 @@ export const settingsIcons = { antialiasing: Calculator, compatibilityMode: Turtle, isEnableEntityCollision: Ungroup, + isEnableForceDirected: Network, + forceDirectedLinkDistance: MoveHorizontal, + forceDirectedLinkStrength: Spline, + forceDirectedCollisionStrength: Ungroup, + forceDirectedVelocityDecay: TrendingUpDown, + forceDirectedMaxMovePerFrame: Move, + forceDirectedConvergenceThreshold: ScanEye, + forceDirectedMinDistance: Minus, language: Languages, showTipsOnUI: AppWindow, useNativeTitleBar: AppWindowMac, @@ -216,4 +225,10 @@ export const settingsIcons = { enableAutoEdgeWidth: Minus, showKeyBindHint: Lightbulb, showEditModeHint: MessageSquareText, + aiApiBaseUrl: Link, + aiApiKey: KeyRound, + aiModel: Cpu, + aiShowTokenCount: Tally4, + moveToAxis: Move, + moveToClamp: Scaling, }; diff --git a/app/src/core/service/controlService/autoLayoutEngine/forceDirectedLayout.tsx b/app/src/core/service/controlService/autoLayoutEngine/forceDirectedLayout.tsx new file mode 100644 index 00000000..1eadfad9 --- /dev/null +++ b/app/src/core/service/controlService/autoLayoutEngine/forceDirectedLayout.tsx @@ -0,0 +1,360 @@ +import { Project, service } from "@/core/Project"; +import { Settings } from "@/core/service/Settings"; +import { ConnectableEntity } from "@/core/stage/stageObject/abstract/ConnectableEntity"; +import { Edge } from "@/core/stage/stageObject/association/Edge"; +import { Section } from "@/core/stage/stageObject/entity/Section"; +import { Vector } from "@graphif/data-structures"; +import { Rectangle } from "@graphif/shapes"; + +/** + * 四叉树:空间分区加速碰撞检测 + * 查询与指定矩形重叠的所有条目,复杂度 O(log N) 每次查询 + */ +class QuadTree { + private boundary: Rectangle; + private capacity: number; + private items: Array<{ rect: Rectangle; data: T }> = []; + private divided = false; + private northWest: QuadTree | null = null; + private northEast: QuadTree | null = null; + private southWest: QuadTree | null = null; + private southEast: QuadTree | null = null; + + constructor(boundary: Rectangle, capacity: number = 4) { + this.boundary = boundary; + this.capacity = capacity; + } + + /** 插入一个条目,返回是否成功 */ + insert(rect: Rectangle, data: T): boolean { + if (!this.boundary.isCollideWith(rect)) return false; + + if (!this.divided) { + if (this.items.length < this.capacity) { + this.items.push({ rect, data }); + return true; + } + this.subdivide(); + } + + return ( + this.northWest!.insert(rect, data) || + this.northEast!.insert(rect, data) || + this.southWest!.insert(rect, data) || + this.southEast!.insert(rect, data) + ); + } + + /** 查询与 range 重叠的所有条目 */ + query(range: Rectangle, result: Array<{ rect: Rectangle; data: T }> = []): Array<{ rect: Rectangle; data: T }> { + if (!this.boundary.isCollideWith(range)) return result; + + for (const item of this.items) { + if (range.isCollideWith(item.rect)) { + result.push(item); + } + } + + if (this.divided) { + this.northWest!.query(range, result); + this.northEast!.query(range, result); + this.southWest!.query(range, result); + this.southEast!.query(range, result); + } + + return result; + } + + /** 清空整棵树 */ + clear(): void { + this.items = []; + this.northWest = null; + this.northEast = null; + this.southWest = null; + this.southEast = null; + this.divided = false; + } + + private subdivide(): void { + const { left, top, right, bottom } = this.boundary; + const midX = (left + right) / 2; + const midY = (top + bottom) / 2; + + this.northWest = new QuadTree(Rectangle.fromEdges(left, top, midX, midY), this.capacity); + this.northEast = new QuadTree(Rectangle.fromEdges(midX, top, right, midY), this.capacity); + this.southWest = new QuadTree(Rectangle.fromEdges(left, midY, midX, bottom), this.capacity); + this.southEast = new QuadTree(Rectangle.fromEdges(midX, midY, right, bottom), this.capacity); + + for (const item of this.items) { + void ( + this.northWest.insert(item.rect, item.data) || + this.northEast.insert(item.rect, item.data) || + this.southWest.insert(item.rect, item.data) || + this.southEast.insert(item.rect, item.data) + ); + } + this.items = []; + this.divided = true; + } +} + +@service("forceDirectedLayout") +export class ForceDirectedLayout { + constructor(private readonly project: Project) {} + + /** 是否正在模拟中 */ + private isSimulating = false; + + /** 上次激活时间,用于开关变化时重新激活 */ + private lastEnabledState = false; + + /** 保存启用力导向前的碰撞设置,退出时恢复 */ + private savedEntityCollision = false; + private savedSectionCollision = false; + + /** 收敛阈值:总动能低于此值停止模拟 */ + private get convergenceThreshold() { + return Settings.forceDirectedConvergenceThreshold; + } + + /** 速度衰减系数(每一帧乘此值) */ + private get velocityDecay() { + return Settings.forceDirectedVelocityDecay; + } + + /** 弹簧力目标距离 */ + private get linkDistance() { + return Settings.forceDirectedLinkDistance; + } + + /** 弹簧力强度 */ + private get linkStrength() { + return Settings.forceDirectedLinkStrength; + } + + /** 最近距离限制(节点不会比这更近) */ + private get minDistance() { + return Settings.forceDirectedMinDistance; + } + + /** 碰撞力强度 */ + private get collisionStrength() { + return Settings.forceDirectedCollisionStrength; + } + + /** 移动限制,防止爆炸 */ + private get maxMovePerFrame() { + return Settings.forceDirectedMaxMovePerFrame; + } + + /** 节点速度映射 */ + private velocities = new Map(); + + tick() { + const isEnabled = Settings.isEnableForceDirected; + + // 开关从关变开:保存碰撞设置,禁用外部碰撞系统 + if (isEnabled && !this.lastEnabledState) { + this.savedEntityCollision = Settings.isEnableEntityCollision; + this.savedSectionCollision = Settings.isEnableSectionCollision; + Settings.isEnableEntityCollision = false; + Settings.isEnableSectionCollision = false; + this.isSimulating = true; + this.velocities.clear(); + } + + // 开关从开变关:恢复碰撞设置,停止模拟 + if (!isEnabled && this.lastEnabledState) { + Settings.isEnableEntityCollision = this.savedEntityCollision; + Settings.isEnableSectionCollision = this.savedSectionCollision; + this.isSimulating = false; + this.velocities.clear(); + } + + this.lastEnabledState = isEnabled; + + if (!this.isSimulating) return; + + // 模拟一帧 + this.simulationTick(); + } + + private simulationTick() { + const allEntities = this.project.stageManager.getEntities(); + const allConnectables = allEntities.filter((e) => e instanceof ConnectableEntity) as ConnectableEntity[]; + + // 收集所有在 Section 内部的子节点 UUID(策略一:Section 作为虚拟大节点, + // 子节点不独立参与力计算,随 Section 移动) + const allSections = allConnectables.filter((e) => e instanceof Section) as Section[]; + const childUuids = new Set(); + for (const section of allSections) { + for (const child of section.children) { + childUuids.add(child.uuid); + } + } + + // 只对不在 Section 内部的实体(含 Section 自身)施力 + const connectableEntities = allConnectables.filter((e) => !childUuids.has(e.uuid)); + + // 没有可移动的实体 + if (connectableEntities.length === 0) return; + + // ===== 初始化速度为0 ===== + for (const entity of connectableEntities) { + if (!this.velocities.has(entity.uuid)) { + this.velocities.set(entity.uuid, Vector.getZero()); + } + } + + // 构建 UUID → Entity 快速查找 + const entityMap = new Map(); + for (const entity of connectableEntities) { + entityMap.set(entity.uuid, entity); + } + + // ===== 1. 计算弹簧力(仅沿边) ===== + // 星系模型:只有有连线的节点之间才有力反馈 + const linkForces = new Map(); + for (const entity of connectableEntities) { + linkForces.set(entity.uuid, Vector.getZero()); + } + + const edges = this.project.stageManager.getAssociations().filter((a) => a instanceof Edge) as Edge[]; + for (const edge of edges) { + const source = edge.source; + const target = edge.target; + // 两个端点都必须在当前场景的可连接实体中 + if (!entityMap.has(source.uuid) || !entityMap.has(target.uuid)) continue; + + const centerSource = source.collisionBox.getRectangle().center; + const centerTarget = target.collisionBox.getRectangle().center; + const delta = centerTarget.subtract(centerSource); + const distance = delta.magnitude(); + + if (distance < 1) continue; + + // 弹簧力:偏离目标距离时产生力 + // < linkDistance → 排斥(推开),> linkDistance → 吸引(拉回) + const displacement = distance - this.linkDistance; + const forceMagnitude = displacement * this.linkStrength; + let force = delta.normalize().multiply(forceMagnitude); + + // 最近距离限制:如果距离小于 minDistance,额外施加强排斥防止重叠 + if (distance < this.minDistance) { + const extraRepel = ((this.minDistance - distance) / this.minDistance) * this.linkStrength * 50; + force = force.add(delta.normalize().multiply(-extraRepel)); + } + + linkForces.set(source.uuid, linkForces.get(source.uuid)!.add(force)); + linkForces.set(target.uuid, linkForces.get(target.uuid)!.add(force.multiply(-1))); + } + + // ===== 2. 碰撞力(基于四叉树,O(N log N)) ===== + const collisionForces = new Map(); + for (const entity of connectableEntities) { + collisionForces.set(entity.uuid, Vector.getZero()); + } + + // 构建四叉树边界 + let minLeft = Infinity, + minTop = Infinity, + maxRight = -Infinity, + maxBottom = -Infinity; + const entityRects = new Map(); + for (const entity of connectableEntities) { + const rect = entity.collisionBox.getRectangle(); + entityRects.set(entity.uuid, rect); + minLeft = Math.min(minLeft, rect.left); + minTop = Math.min(minTop, rect.top); + maxRight = Math.max(maxRight, rect.right); + maxBottom = Math.max(maxBottom, rect.bottom); + } + const bounds = Rectangle.fromEdges(minLeft - 1, minTop - 1, maxRight + 1, maxBottom + 1); + + // 插入四叉树 + const tree = new QuadTree(bounds, 4); + for (const entity of connectableEntities) { + tree.insert(entityRects.get(entity.uuid)!, entity); + } + + // 对每个实体查询四叉树找碰撞 + for (const entity of connectableEntities) { + const rect = entityRects.get(entity.uuid)!; + const candidates = tree.query(rect); + let netForce = Vector.getZero(); + + for (const candidate of candidates) { + if (candidate.data === entity) continue; + + const rectB = candidate.rect; + const overlap = rect.getOverlapSize(rectB); + const delta = rectB.center.subtract(rect.center); + const forceDir = delta.magnitude() < 1 ? new Vector(1, 0) : delta.normalize(); + const force = forceDir.multiply(Math.min(Math.abs(overlap.x), Math.abs(overlap.y)) * this.collisionStrength); + netForce = netForce.add(force.multiply(-1)); + } + + collisionForces.set(entity.uuid, netForce); + } + + // ===== 3. 合并力,更新速度 ===== + let totalKineticEnergy = 0; + const movedEntities: Array<{ entity: ConnectableEntity; delta: Vector }> = []; + + for (const entity of connectableEntities) { + const link = linkForces.get(entity.uuid) || Vector.getZero(); + const collision = collisionForces.get(entity.uuid) || Vector.getZero(); + + let totalForce = link.add(collision); + + // 限制单帧力的大小,防止爆炸 + const forceMagnitude = totalForce.magnitude(); + if (forceMagnitude > this.maxMovePerFrame) { + totalForce = totalForce.normalize().multiply(this.maxMovePerFrame); + } + + // 更新速度(F = ma, 假设质量=1) + let velocity = this.velocities.get(entity.uuid) || Vector.getZero(); + velocity = velocity.add(totalForce); + // 衰减 + velocity = velocity.multiply(this.velocityDecay); + this.velocities.set(entity.uuid, velocity); + + const speed = velocity.magnitude(); + totalKineticEnergy += speed * speed; + + if (speed > 0.1) { + movedEntities.push({ entity, delta: velocity }); + } + } + + // ===== 4. 应用移动 ===== + for (const { entity, delta } of movedEntities) { + entity.move(delta); + } + + // 更新所有 Section 的大小和位置(策略一:Section 自适应包裹子节点) + const sections = connectableEntities.filter((e) => e instanceof Section) as Section[]; + for (const section of sections) { + section.adjustLocationAndSize(); + } + + // ===== 5. 收敛检测 ===== + if (totalKineticEnergy < this.convergenceThreshold) { + this.isSimulating = false; + this.velocities.clear(); + } + } + + /** 重新激活力导向模拟 */ + public restartSimulation() { + this.isSimulating = true; + this.velocities.clear(); + } + + /** 停止力导向模拟 */ + public stopSimulation() { + this.isSimulating = false; + this.velocities.clear(); + } +} diff --git a/app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx b/app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx index 82450cf3..988f9083 100644 --- a/app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx +++ b/app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx @@ -220,6 +220,11 @@ export class ControllerEntityClickSelectAndMoveClass extends ControllerClass { } } + // 力导向模式下,拖拽后重启仿真,防止收敛检测误判为停止 + if (Settings.isEnableForceDirected) { + this.project.forceDirectedLayout.restartSimulation(); + } + // 预瞄反馈 if (Settings.enableDragAutoAlign) { this.project.autoAlign.preAlignAllSelected(); @@ -265,6 +270,11 @@ export class ControllerEntityClickSelectAndMoveClass extends ControllerClass { } this.project.historyManager.recordStep(); // 记录一次历史 + + // 力导向模式下,拖拽结束后重启仿真,防止收敛检测误判为停止 + if (Settings.isEnableForceDirected) { + this.project.forceDirectedLayout.restartSimulation(); + } } } diff --git a/app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx b/app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx index 60e3194c..432ed46b 100644 --- a/app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx +++ b/app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx @@ -2641,6 +2641,103 @@ export const allKeyBinds: KeyBindItem[] = [ if (project) TextNodeSmartTools.generateSummaryBySelectedTextNodeTextWithAI(project); }, }, + { + id: "moveAllToOrigin", + defaultKey: "C-S-H", + icon: Focus, + when: whenHasProject, + onPress: (project) => { + if (!project) { + toast.warning("请先打开工程文件"); + return; + } + const AXIS = Settings.moveToAxis; + const CLAMP = Settings.moveToClamp; + const entities = project.stageManager.getEntities(); + + // 收集 Section 内部子节点 UUID,避免重复移动 + const sections = entities.filter((e) => e instanceof Section) as Section[]; + const childUuids = new Set(); + for (const section of sections) { + for (const child of section.children) { + childUuids.add(child.uuid); + } + } + // 只操作可连接实体(节点/分组框),跳过涂鸦和 Section 内部子节点 + const movableEntities = entities.filter( + (e) => e instanceof ConnectableEntity && !childUuids.has(e.uuid), + ) as ConnectableEntity[]; + if (movableEntities.length === 0) { + toast.warning("没有可移动的实体"); + return; + } + + // 根据设置选择操作的轴 + const useX = AXIS === "x" || AXIS === "both"; + + // 分两组:正方向组(> CLAMP)和负方向组(< -CLAMP) + const posGroup: ConnectableEntity[] = []; + const negGroup: ConnectableEntity[] = []; + for (const entity of movableEntities) { + const center = entity.collisionBox.getRectangle().center; + const coord = useX ? center.x : center.y; + if (coord > CLAMP) posGroup.push(entity); + else if (coord < -CLAMP) negGroup.push(entity); + } + + // 正方向组:最近节点落到 CLAMP,同组统一偏移 + let posOffset = 0; + if (posGroup.length > 0) { + const coords = posGroup.map((e) => { + const c = e.collisionBox.getRectangle().center; + return useX ? c.x : c.y; + }); + posOffset = CLAMP - Math.min(...coords); + } + // 负方向组:最近节点落到 -CLAMP,同组统一偏移 + let negOffset = 0; + if (negGroup.length > 0) { + const coords = negGroup.map((e) => { + const c = e.collisionBox.getRectangle().center; + return useX ? c.x : c.y; + }); + negOffset = -CLAMP - Math.max(...coords); + } + + if (posOffset === 0 && negOffset === 0) { + const axisLabel = useX ? "X" : "Y"; + toast.success(`所有节点 ${axisLabel} 已在 [-${CLAMP}, ${CLAMP}] 范围内`); + return; + } + + // 禁用碰撞,分两组各自移动 + const savedEntityCollision = Settings.isEnableEntityCollision; + const savedSectionCollision = Settings.isEnableSectionCollision; + Settings.isEnableEntityCollision = false; + Settings.isEnableSectionCollision = false; + + const makeOffset = (d: number) => (useX ? new Vector(d, 0) : new Vector(0, d)); + + if (posOffset !== 0) { + const offset = makeOffset(posOffset); + for (const entity of posGroup) entity.move(offset); + } + if (negOffset !== 0) { + const offset = makeOffset(negOffset); + for (const entity of negGroup) entity.move(offset); + } + + Settings.isEnableEntityCollision = savedEntityCollision; + Settings.isEnableSectionCollision = savedSectionCollision; + + project.camera.saveCameraState(); + project.camera.resetBySelected(); + const axisLabel = useX ? "X" : "Y"; + toast.success( + `${axisLabel}轴 正侧 ${posGroup.length} 个偏移 ${posOffset.toFixed(0)},负侧 ${negGroup.length} 个偏移 ${negOffset.toFixed(0)}`, + ); + }, + }, ]; export function getKeyBindTypeById(id: string): "global" | "software" { diff --git a/app/src/locales/en.yml b/app/src/locales/en.yml index e93cb28e..13f858f8 100644 --- a/app/src/locales/en.yml +++ b/app/src/locales/en.yml @@ -296,6 +296,32 @@ settings: title: Enable Section Collision description: | When enabled, sibling sections will automatically push each other apart to avoid overlapping. + isEnableForceDirected: + title: Enable Force-Directed Layout + description: | + When enabled, nodes simulate physical forces (repulsion, spring, centering) to automatically arrange the layout. + Nodes can be dragged, and the force simulation continues after release. + forceDirectedLinkDistance: + title: Link Distance + description: Target distance between connected nodes in the force-directed layout. + forceDirectedLinkStrength: + title: Link Strength + description: Spring force strength between connected nodes. Higher values pull nodes together more quickly. + forceDirectedCollisionStrength: + title: Collision Strength + description: Force applied when nodes overlap. Higher values push overlapping nodes apart more aggressively. + forceDirectedVelocityDecay: + title: Velocity Decay + description: Speed damping per frame. Lower values slow nodes down faster, making the simulation converge sooner. + forceDirectedMaxMovePerFrame: + title: Max Move Per Frame + description: Maximum distance a node can move in a single frame. Prevents simulation from exploding. + forceDirectedConvergenceThreshold: + title: Convergence Threshold + description: Total kinetic energy below which the simulation stops. Higher values stop the simulation earlier. + forceDirectedMinDistance: + title: Min Distance + description: Minimum allowed distance between connected nodes. Prevents nodes from overlapping completely. autoNamerTemplate: title: Auto-Naming Template for Node Creation description: | @@ -725,6 +751,22 @@ settings: compatibilityMode: description: "When enabled, another render method will be used.\n" title: Compatibility Mode + moveToAxis: + title: Clamp Target Axis + description: | + Choose which axis to operate on (Ctrl+Shift+H). + - Y (default): clamp top/bottom to horizontal lines + - X: clamp left/right to vertical lines + - Both: process both axes + options: + y: Y-Axis Only + x: X-Axis Only + both: Both Axes + moveToClamp: + title: Clamp Threshold + description: | + Boundary value for clamping. Nodes exceeding this range + are pulled back. Default 200, range 10~10000. autoRefreshStageByMouseAction: description: "When enabled, mouse actions (camera view drags) will automatically refresh the stage.\nThis prevents manual refreshes for images that fail to load @@ -1251,6 +1293,9 @@ keyBinds: toggleSectionLock: title: Lock/Unlock Section Box description: Toggle lock state of selected section boxes. Locked sections prevent moving internal objects. + toggleForceDirected: + title: Toggle Force-Directed Layout + description: Toggle the force-directed layout on and off reverseEdges: title: Reverse Connection Direction description: | @@ -1796,6 +1841,13 @@ keyBindsGroup: adjustSelectedTextNodeWidthAverage: title: Unify Width to Average description: Only works on text nodes, unify the width of all selected nodes to the average value (Shortcut:4→6→5) + moveAllToOrigin: + title: Clamp Nodes to Origin Range + description: | + Clamp nodes whose X/Y coordinates exceed [-200, 200] back into that range. + Nodes already within the range stay put. + Useful for pulling scattered nodes back into view after large deletions. + Shortcut: Ctrl+Shift+H controlSettingsGroup: mouse: diff --git a/app/src/locales/id.yml b/app/src/locales/id.yml index d41b654a..1f8a4c9b 100644 --- a/app/src/locales/id.yml +++ b/app/src/locales/id.yml @@ -404,6 +404,48 @@ settings: title: Aktifkan Tabrakan Bagian description: | Saat diaktifkan, bagian-bagian yang berdampingan akan saling mendorong untuk menghindari tumpang tindih. + isEnableForceDirected: + title: Aktifkan Tata Letak Force-Directed + description: | + Saat diaktifkan, node akan mensimulasikan gaya fisik (tolakan, pegas, sentripetal) untuk mengatur tata letak secara otomatis. + Node dapat diseret, dan simulasi gaya akan berlanjut setelah dilepaskan. + forceDirectedLinkDistance: + title: Jarak Tautan + description: Jarak target antara node yang terhubung dalam tata letak force-directed. + forceDirectedLinkStrength: + title: Kekuatan Tautan + description: Kekuatan gaya pegas antara node yang terhubung. Nilai lebih tinggi menarik node lebih cepat ke jarak target. + forceDirectedCollisionStrength: + title: Kekuatan Tabrakan + description: Gaya yang diterapkan saat node tumpang tindih. Nilai lebih tinggi mendorong node lebih kuat. + forceDirectedVelocityDecay: + title: Peluruhan Kecepatan + description: Redaman kecepatan per frame. Nilai lebih rendah memperlambat node lebih cepat. + forceDirectedMaxMovePerFrame: + title: Gerakan Maks per Frame + description: Jarak maksimum node dapat bergerak dalam satu frame. Mencegah simulasi meledak. + forceDirectedConvergenceThreshold: + title: Ambang Konvergensi + description: Energi kinetik total di bawah ini simulasi berhenti. Nilai lebih tinggi menghentikan simulasi lebih awal. + forceDirectedMinDistance: + title: Jarak Minimum + description: Jarak minimum yang diizinkan antara node yang terhubung. Mencegah node tumpang tindih sepenuhnya. + moveToAxis: + title: Sumbu Target Jepitan + description: | + Pilih sumbu untuk operasi jepitan (Ctrl+Shift+H). + - Y (default): jepit atas/bawah ke garis horizontal + - X: jepit kiri/kanan ke garis vertikal + - Kedua: proses kedua sumbu + options: + y: Hanya Sumbu Y + x: Hanya Sumbu X + both: Kedua Sumbu + moveToClamp: + title: Ambang Jepitan + description: | + Nilai batas untuk penjepitan. Node yang melebihi rentang ini + akan ditarik kembali. Default 200, rentang 10~10000. autoRefreshStageByMouseAction: title: Segarkan Panggung Otomatis Saat Operasi Mouse description: | @@ -1292,6 +1334,9 @@ keyBinds: toggleSectionLock: title: Kunci/Buka Kunci Kotak Section description: Alihkan status kunci kotak section yang dipilih. Section terkunci mencegah pergerakan objek internal. + toggleForceDirected: + title: Alihkan Tata Letak Force-Directed + description: Mengaktifkan atau menonaktifkan tata letak force-directed reverseEdges: title: Balikkan Arah Garis description: | @@ -1847,6 +1892,13 @@ keyBinds: description: | Ekspor struktur jaringan yang dipilih ke format diagram Mermaid dan salin ke clipboard Dapat ditempel di editor yang mendukung Mermaid + moveAllToOrigin: + title: Jepit Node ke Rentang Origin + description: | + Jepit node yang koordinat X/Y-nya melebihi [-200, 200] kembali ke dalam rentang tersebut. + Node yang sudah berada dalam rentang tidak berubah. + Berguna untuk menarik node yang tersebar jauh kembali ke tengah. + Pintasan: Ctrl+Shift+H sounds: soundEnabled: Sakelar Suara diff --git a/app/src/locales/zh_CN.yml b/app/src/locales/zh_CN.yml index fc6649b9..b062bcd3 100644 --- a/app/src/locales/zh_CN.yml +++ b/app/src/locales/zh_CN.yml @@ -414,6 +414,48 @@ settings: title: 启用框碰撞 description: | 开启后,框与框之间会自动进行碰撞排斥(推开重叠的同级框),避免框重叠。 + isEnableForceDirected: + title: 启用力导向布局 + description: | + 开启后,节点之间会模拟物理力(斥力、弹簧力、向心力),自动排列布局。 + 节点可以被拖拽,松开后力导向会继续作用。 + forceDirectedLinkDistance: + title: 连线目标距离 + description: 力导向布局中连线节点之间的目标距离。 + forceDirectedLinkStrength: + title: 弹簧力强度 + description: 连线节点之间的弹簧力强度。值越大,节点越快地拉向目标距离。 + forceDirectedCollisionStrength: + title: 碰撞力强度 + description: 节点重叠时施加的排斥力强度。值越大,重叠节点被推开得越剧烈。 + forceDirectedVelocityDecay: + title: 速度衰减 + description: 每帧的速度阻尼系数。值越小,节点减速越快,仿真越快收敛。 + forceDirectedMaxMovePerFrame: + title: 每帧最大移动距离 + description: 节点每帧可移动的最大距离。防止仿真爆炸。 + forceDirectedConvergenceThreshold: + title: 收敛阈值 + description: 总动能低于此值时仿真停止。值越高,仿真越早停止。 + forceDirectedMinDistance: + title: 最小距离 + description: 连线节点之间允许的最小距离。防止节点完全重叠。 + moveToAxis: + title: 夹紧操作的目标轴 + description: | + 选择按哪个轴进行夹紧操作(Ctrl+Shift+H)。 + - Y轴(默认):上下夹紧到水平线 + - X轴:左右夹紧到垂直线 + - 双向:同时处理两个轴 + options: + y: 仅Y轴 + x: 仅X轴 + both: 双向 + moveToClamp: + title: 夹紧范围阈值 + description: | + 设置夹紧操作的边界值。节点坐标超出此范围时会被拉回。 + 默认值200,范围10~10000。 autoRefreshStageByMouseAction: title: 鼠标操作时自动刷新舞台 description: | @@ -1294,6 +1336,9 @@ keyBinds: toggleSectionLock: title: 锁定/解锁分组框 description: 切换选中分组框的锁定状态,锁定后内部物体不可移动 + toggleForceDirected: + title: 切换力导向布局 + description: 切换力导向布局的开启和关闭 reverseEdges: title: 反转连线的方向 description: | @@ -1969,6 +2014,13 @@ keyBinds: swapTwoSelectedEntitiesPositions: title: 交换位置 description: 交换两个选中实体的位置 + moveAllToOrigin: + title: 夹紧节点到原点范围 + description: | + 将 X/Y 坐标超出 [-200, 200] 范围的节点夹紧回该范围内, + 不在此范围内的节点保持不动。 + 方便在大删改后快速把散落远处的节点拉回视野中央。 + 快捷键 Ctrl+Shift+H sounds: soundEnabled: 音效开关 diff --git a/app/src/locales/zh_TW.yml b/app/src/locales/zh_TW.yml index 1740f566..2b3acb9b 100644 --- a/app/src/locales/zh_TW.yml +++ b/app/src/locales/zh_TW.yml @@ -414,6 +414,48 @@ settings: title: 啟用框碰撞 description: | 開啟後,框與框之間會自動進行碰撞排斥(推開重疊的同級框),避免框重疊。 + isEnableForceDirected: + title: 啟用力導向佈局 + description: | + 開啟後,節點之間會模擬物理力(斥力、彈簧力、向心力),自動排列布局。 + 節點可以被拖拽,鬆開後力導向會繼續作用。 + forceDirectedLinkDistance: + title: 連線目標距離 + description: 力導向佈局中連線節點之間的目標距離。 + forceDirectedLinkStrength: + title: 彈簧力強度 + description: 連線節點之間的彈簧力強度。值越大,節點越快地拉向目標距離。 + forceDirectedCollisionStrength: + title: 碰撞力強度 + description: 節點重疊時施加的排斥力強度。值越大,重疊節點被推開得越劇烈。 + forceDirectedVelocityDecay: + title: 速度衰減 + description: 每幀的速度阻尼係數。值越小,節點減速越快,仿真越快收斂。 + forceDirectedMaxMovePerFrame: + title: 每幀最大移動距離 + description: 節點每幀可移動的最大距離。防止仿真爆炸。 + forceDirectedConvergenceThreshold: + title: 收斂閾值 + description: 總動能低於此值時仿真停止。值越高,仿真越早停止。 + forceDirectedMinDistance: + title: 最小距離 + description: 連線節點之間允許的最小距離。防止節點完全重疊。 + moveToAxis: + title: 夾緊操作的目標軸 + description: | + 選擇按哪個軸進行夾緊操作(Ctrl+Shift+H)。 + - Y軸(默認):上下夾緊到水平線 + - X軸:左右夾緊到垂直線 + - 雙向:同時處理兩個軸 + options: + y: 僅Y軸 + x: 僅X軸 + both: 雙向 + moveToClamp: + title: 夾緊範圍閾值 + description: | + 設置夾緊操作的邊界值。節點坐標超出此範圍時會被拉回。 + 默認值200,範圍10~10000。 autoRefreshStageByMouseAction: title: 鼠標操作時自動刷新舞臺 description: | @@ -921,17 +963,26 @@ settings: aiApiBaseUrl: title: AI API 地址 description: | - 目前僅支持 OpenAI 格式的 API + OpenAI 兼容 API 的基礎地址。 + 默認: https://generativelanguage.googleapis.com/v1beta/openai/ (Gemini API) + 可改為 https://api.openai.com/v1 (OpenAI) 或其他兼容 API。 aiApiKey: title: AI API 密鑰 description: | - 密鑰將會明文存儲在本地 + API 的認證密鑰。密鑰僅存儲在本地。 + Gemini 密鑰: https://aistudio.google.com/apikey + OpenAI 密鑰: https://platform.openai.com/api-keys aiModel: title: AI 模型 + description: | + 使用的 AI 模型名稱。 + 默認: gemini-2.5-flash + OpenAI: gpt-4o, gpt-4-turbo + 其他: 按服務商提供的模型名填寫 aiShowTokenCount: - title: 顯示 AI 消耗的token數 + title: 顯示 Token 計數 description: | - 啟用後,在 AI 操作時顯示消耗的token數 + 在 AI 聊天窗口底部顯示輸入/輸出的 Token 數量。 cacheTextAsBitmap: title: 開啟位圖式渲染文本 description: | @@ -1294,6 +1345,9 @@ keyBinds: toggleSectionLock: title: 鎖定/解鎖分組框 description: 切換選中分組框的鎖定狀態,鎖定後內部物體不可移動 + toggleForceDirected: + title: 切換力導向佈局 + description: 切換力導向佈局的開啟和關閉 reverseEdges: title: 反轉連線的方向 description: | @@ -1969,6 +2023,13 @@ keyBinds: swapTwoSelectedEntitiesPositions: title: 交換位置 description: 交換兩個選中實體的位置 + moveAllToOrigin: + title: 夾緊節點到原點範圍 + description: | + 將 X/Y 座標超出 [-200, 200] 範圍的節點夾緊回該範圍內, + 不在此範圍內的節點保持不動。 + 方便在大刪改後快速把散落遠處的節點拉回視野中央。 + 快捷鍵 Ctrl+Shift+H sounds: soundEnabled: 音效開關 diff --git a/app/src/locales/zh_TWC.yml b/app/src/locales/zh_TWC.yml index 5ee1fe67..2f902021 100644 --- a/app/src/locales/zh_TWC.yml +++ b/app/src/locales/zh_TWC.yml @@ -441,6 +441,48 @@ settings: title: 启用框碰撞 description: | 开启后,框与框之间会自动进行碰撞排斥(推开重叠的同级框),避免框重叠。 + isEnableForceDirected: + title: 启用力导向布局 + description: | + 开启后,节点之间会模拟物理力(斥力、弹簣力、向心力),自动排列布局。 + 节点可以被拖拽,松开后力导向会继续作用。 + forceDirectedLinkDistance: + title: 连线目标距离 + description: 力导向布局中连线节点之间的目标距离。 + forceDirectedLinkStrength: + title: 弹簣力强度 + description: 连线节点之间的弹簣力强度。值越大,节点越快地拉向目标距离。 + forceDirectedCollisionStrength: + title: 碰撞力强度 + description: 节点重叠时施加的排斥力强度。值越大,重叠节点被推开得越剧烈。 + forceDirectedVelocityDecay: + title: 速度衰减 + description: 每帧的速度阻尼系数。值越小,节点减速越快,仿真越快收敛。 + forceDirectedMaxMovePerFrame: + title: 每帧最大移动距离 + description: 节点每帧可移动的最大距离。防止仿真爆炸。 + forceDirectedConvergenceThreshold: + title: 收敛阈值 + description: 总动能低于此值时仿真停止。值越高,仿真越早停止。 + forceDirectedMinDistance: + title: 最小距离 + description: 连线节点之间允许的最小距离。防止节点完全重叠。 + moveToAxis: + title: 夾緊操作的目標軸 + description: | + 選擇按哪個軸進行夾緊操作(Ctrl+Shift+H)。 + - Y軸(默認):上下夾緊到水平線 + - X軸:左右夾緊到垂直線 + - 雙向:同時處理兩個軸 + options: + y: 僅Y軸 + x: 僅X軸 + both: 雙向 + moveToClamp: + title: 夾緊範圍閾值 + description: | + 設置夾緊操作的邊界值。節點坐標超出此範圍時會被拉回。 + 默認值200,範圍10~10000。 autoRefreshStageByMouseAction: title: 滑鼠操作時自動刷新舞台 description: '開啟後,滑鼠操作(拖曳移動視野)會自動重新整理舞台 @@ -1242,6 +1284,9 @@ keyBinds: toggleSectionLock: title: 鎖定/解鎖分组框 description: 切換選中分组框的鎖定狀態,鎖定後內部物體不可移動 + toggleForceDirected: + title: 切換力導向佈局 + description: 切換力導向佈局的開啟和關閉 reverseEdges: title: 反转连线的方向 description: '按下後,選取的連線的方向會變成相反方向 @@ -1787,6 +1832,11 @@ keyBinds: setWindowToMiniSize: title: 設置窗口為迷你大小 description: 將窗口大小設置為設置中配置的迷你窗口寬度和高度。 + moveAllToOrigin: + title: 全部移回原點 + description: | + 將所有節點整體平移回原點附近,方便在大刪改後重新佈局。 + 快捷鍵 Ctrl+Shift+H sounds: soundEnabled: 音效開關 diff --git a/app/src/sub/SettingsWindow/keybinds.tsx b/app/src/sub/SettingsWindow/keybinds.tsx index 136cd51b..a3773597 100644 --- a/app/src/sub/SettingsWindow/keybinds.tsx +++ b/app/src/sub/SettingsWindow/keybinds.tsx @@ -613,6 +613,7 @@ export const shortcutKeysGroups: ShortcutKeysGroup[] = [ "CameraPageMoveDown", "CameraPageMoveLeft", "CameraPageMoveRight", + "moveAllToOrigin", ], }, { diff --git a/app/src/sub/SettingsWindow/settings.tsx b/app/src/sub/SettingsWindow/settings.tsx index cb75d72b..a316cdbf 100644 --- a/app/src/sub/SettingsWindow/settings.tsx +++ b/app/src/sub/SettingsWindow/settings.tsx @@ -304,7 +304,18 @@ const categories = { "newNodeScaleByCamera", "newNodeScaleByCameraOffset", ], - section: ["isEnableSectionCollision"], + section: ["isEnableSectionCollision", "isEnableForceDirected"], + forceDirected: [ + "forceDirectedLinkDistance", + "forceDirectedLinkStrength", + "forceDirectedCollisionStrength", + "forceDirectedVelocityDecay", + "forceDirectedMaxMovePerFrame", + "forceDirectedConvergenceThreshold", + "forceDirectedMinDistance", + "moveToAxis", + "moveToClamp", + ], edge: [ "allowAddCycleEdge", "enableDragNodeShakeDetachFromEdge", @@ -363,6 +374,7 @@ const categoryIcons = { objectSelect: SquareDashedMousePointer, textNode: TextCursorInput, section: SquareDashedTopSolid, + forceDirected: Network, edge: SplinePointer, generateNode: Network, gamepad: Gamepad2,