mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
refactor: replace svg rect with html button
Only use svg to draw lines
This commit is contained in:
1 parent
22c71d367c
commit
119f8c552a
1 file changed
+78
-128
+78
-128
@@ -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"
|
||||||
v-for="edge in graphData.edges"
|
:style="{ transform: `translate(${offsetX}px, ${offsetY}px)` }"
|
||||||
:key="`${edge.source.id}-${edge.target.id}`"
|
>
|
||||||
>
|
<svg class="absolute overflow-visible pointer-events-none">
|
||||||
<path
|
<path
|
||||||
:d="curvedPath(edge.source, edge.target)"
|
v-for="edge in graphData.edges"
|
||||||
|
:key="`${edge.source.id}-${edge.target.id}`"
|
||||||
|
:d="edgePath(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 {
|
}
|
||||||
stroke: #adadb5;
|
.edge.unlocked {
|
||||||
stroke-dasharray: none;
|
stroke: #adadb5;
|
||||||
}
|
stroke-dasharray: none;
|
||||||
&.locked {
|
}
|
||||||
stroke: #454552;
|
.edge.locked {
|
||||||
stroke-dasharray: 5, 5;
|
stroke: #454552;
|
||||||
}
|
stroke-dasharray: 5, 5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user