refactor: replace svg rect with html button

Only use svg to draw lines
This commit is contained in:
Justin Lin committed 2026-10-09 23:23:02 +11:00
1 parent 22c71d367c
commit 119f8c552a
1 file changed
+73 -123
+73 -123
View File
@@ -11,49 +11,11 @@ const { t, locale } = useI18n()
const store = useUserProfile() const store = useUserProfile()
const toast = useToast() const toast = useToast()
const svgWidth = ref(window.innerWidth)
const svgHeight = ref(window.innerHeight)
const marginX = 120 const marginX = 120
const marginY = 120 const marginY = 120
const nodeWidth = 160 const nodeWidth = 160
const nodeHeight = 50 const nodeHeight = 50
const maxTextWidth = nodeWidth - 20
const fontSize = 16
const maxLines = 2
// Approximate glyph width: wide (CJK etc.) chars take a full em, others about half
function charWidth(ch: string) {
return ch.charCodeAt(0) > 0x2e80 ? fontSize : fontSize * 0.55
}
function textWidth(s: string) {
return [...s].reduce((w, ch) => w + charWidth(ch), 0)
}
// SVG text does not wrap, so split the title into lines that fit the node
function wrapTitle(title: string): string[] {
const lines: string[] = []
let current = ''
// Keep whole words together for spaced languages; CJK splits per character
const tokens = title.match(/[⺀-￿]|\S+\s*/g) ?? []
for (const token of tokens) {
if (current && textWidth((current + token).trimEnd()) > maxTextWidth) {
lines.push(current.trimEnd())
current = ''
}
current += token
}
if (current) lines.push(current.trimEnd())
if (lines.length <= maxLines) return lines
const kept = lines.slice(0, maxLines)
let last = kept[maxLines - 1]
while (last && textWidth(last + '…') > maxTextWidth) last = last.slice(0, -1)
kept[maxLines - 1] = last + '…'
return kept
}
const opened = ref<Node | null>(null) const opened = ref<Node | null>(null)
type Node = { type Node = {
@@ -82,14 +44,18 @@ const dragStartY = ref(0)
const offsetX = ref(0) const offsetX = ref(0)
const offsetY = ref(0) const offsetY = ref(0)
function startDrag(event: MouseEvent) { function startDrag(event: PointerEvent) {
isDragging.value = true isDragging.value = true
dragStartX.value = event.clientX - offsetX.value dragStartX.value = event.clientX - offsetX.value
dragStartY.value = event.clientY - offsetY.value dragStartY.value = event.clientY - offsetY.value
} }
function drag(event: MouseEvent) { function drag(event: PointerEvent) {
if (!isDragging.value) return if (!isDragging.value) return
// Capture only once dragging starts so plain clicks still reach the node buttons
const el = event.currentTarget as HTMLElement
if (!el.hasPointerCapture(event.pointerId))
el.setPointerCapture(event.pointerId)
offsetX.value = event.clientX - dragStartX.value offsetX.value = event.clientX - dragStartX.value
offsetY.value = event.clientY - dragStartY.value offsetY.value = event.clientY - dragStartY.value
} }
@@ -111,10 +77,8 @@ async function computeGraphData() {
id: node.id, id: node.id,
title: node.title, title: node.title,
x: marginX * node.layer * 2, x: marginX * node.layer * 2,
y: // Centered on 0; the world container is anchored at the viewport's vertical center
marginY * node.index - y: (node.index - (dag.getLayer(node.id) + 1) / 2) * marginY,
(marginX * dag.getLayer(node.id)) / 2 +
svgHeight.value / 2,
completed: store.progress[node.id]?.completed || false, completed: store.progress[node.id]?.completed || false,
unlocked: node.requirements.every( unlocked: node.requirements.every(
(req: string) => store.progress[req]?.completed (req: string) => store.progress[req]?.completed
@@ -159,14 +123,10 @@ watch(locale, () => {
computeGraphData() computeGraphData()
}) })
const curvedPath = computed(() => { const edgePath = computed(() => {
return (source: Node, target: Node) => { return (source: Node, target: Node) => {
// const dx = target.x - source.x const mx = (source.x + target.x) / 2
// const dy = target.y - source.y return `M${source.x},${source.y}C${mx},${source.y} ${mx},${target.y} ${target.x},${target.y}`
// const dr = Math.sqrt(dx * dx + dy * dy)
const dr = 0
const sweep = target.index > source.index ? 0 : 1 // Determines if the arc should curve up or down based on node index
return `M${source.x},${source.y}A${dr},${dr} 0 0,${sweep} ${target.x},${target.y}`
} }
}) })
@@ -196,58 +156,44 @@ const edgeStyle = computed(() => {
> >
{{ t('quest_map.intro') }} {{ t('quest_map.intro') }}
</h1> </h1>
<svg <div
:width="svgWidth" class="absolute inset-0 overflow-hidden select-none"
:height="svgHeight" :class="isDragging ? 'cursor-grabbing' : 'cursor-grab'"
class="absolute" style="touch-action: none"
@mousedown="startDrag" @pointerdown="startDrag"
@mousemove="drag" @pointermove="drag"
@mouseup="endDrag" @pointerup="endDrag"
@mouseleave="endDrag" @pointercancel="endDrag"
> >
<g :transform="`translate(${offsetX}, ${offsetY})`"> <div
<g class="absolute top-1/2 left-0"
:style="{ transform: `translate(${offsetX}px, ${offsetY}px)` }"
>
<svg class="absolute overflow-visible pointer-events-none">
<path
v-for="edge in graphData.edges" v-for="edge in graphData.edges"
:key="`${edge.source.id}-${edge.target.id}`" :key="`${edge.source.id}-${edge.target.id}`"
> :d="edgePath(edge.source, edge.target)"
<path
:d="curvedPath(edge.source, edge.target)"
:class="['edge', edgeStyle(edge)]" :class="['edge', edgeStyle(edge)]"
/> />
</g> </svg>
<g <button
v-for="node in graphData.nodes" v-for="node in graphData.nodes"
:key="node.id" :key="node.id"
type="button"
:class="['node', nodeStyle(node)]"
:style="{
left: `${node.x - nodeWidth / 2}px`,
top: `${node.y - nodeHeight / 2}px`,
width: `${nodeWidth}px`,
height: `${nodeHeight}px`,
}"
@click="handleNodeClick(node)" @click="handleNodeClick(node)"
> >
<rect <span class="line-clamp-2">{{ node.title }}</span>
:x="node.x - nodeWidth / 2" </button>
:y="node.y - nodeHeight / 2" </div>
:width="nodeWidth" </div>
:height="nodeHeight"
rx="10"
ry="10"
:class="['node', nodeStyle(node)]"
/>
<text
:x="node.x"
:y="node.y"
text-anchor="middle"
dominant-baseline="central"
:class="['node-text', nodeStyle(node)]"
>
<tspan
v-for="(line, i) in wrapTitle(node.title)"
:key="i"
:x="node.x"
:dy="i === 0 ? `${-(wrapTitle(node.title).length - 1) * 0.6}em` : '1.2em'"
>
{{ line }}
</tspan>
</text>
</g>
</g>
</svg>
<QuestIntro <QuestIntro
:questTitle="opened.title" :questTitle="opened.title"
:questId="opened.id" :questId="opened.id"
@@ -260,45 +206,49 @@ const edgeStyle = computed(() => {
</template> </template>
<style scoped> <style scoped>
svg {
overflow: visible;
}
.node { .node {
&.completed { position: absolute;
fill: #6cf76c; display: flex;
} align-items: center;
&.unlocked { justify-content: center;
fill: #8c8c92; padding: 0 0.5rem;
} text-align: center;
&.locked { border-radius: 10px;
fill: #505050;
}
}
.node-text {
cursor: pointer; cursor: pointer;
&.completed { transition:
fill: #1d1d1d; filter 0.15s,
} box-shadow 0.15s;
&.unlocked { }
fill: #ffffff; .node:hover {
} filter: brightness(1.15);
&.locked { }
fill: #a0a0a0; .node:focus-visible {
} outline: none;
box-shadow: 0 0 0 3px #00ff00;
}
.node.completed {
background-color: #6cf76c;
color: #1d1d1d;
}
.node.unlocked {
background-color: #8c8c92;
color: #ffffff;
}
.node.locked {
background-color: #505050;
color: #a0a0a0;
} }
.edge { .edge {
stroke-width: 3; stroke-width: 3;
fill: none; fill: none;
&.unlocked { }
.edge.unlocked {
stroke: #adadb5; stroke: #adadb5;
stroke-dasharray: none; stroke-dasharray: none;
} }
&.locked { .edge.locked {
stroke: #454552; stroke: #454552;
stroke-dasharray: 5, 5; stroke-dasharray: 5, 5;
}
} }
</style> </style>