mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Feat map (#152)
This commit is contained in:
6 files changed
+226
-174
No files matched your search
@@ -22,7 +22,6 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"axios": "^1.6.0",
|
||||
"dompurify": "^3.0.6",
|
||||
"echarts": "^5.4.3",
|
||||
"marked": "^10.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.7.1",
|
||||
|
||||
+216
-148
@@ -1,163 +1,145 @@
|
||||
<!-- eslint-disable no-console -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { use, init } from 'echarts/core'
|
||||
import { GraphChart } from 'echarts/charts'
|
||||
import { SVGRenderer } from 'echarts/renderers'
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
DataZoomComponent,
|
||||
} from 'echarts/components'
|
||||
import { DAG } from '@linux-odyssey/utils'
|
||||
import api from '../utils/api'
|
||||
import { NodeImage } from '../img/svg.js'
|
||||
import { getQuests } from '../utils/api'
|
||||
import useUserProfile from '../store/userProfile'
|
||||
|
||||
const store = useUserProfile()
|
||||
const marginX = 500
|
||||
const marginY = 300
|
||||
const chartContainer = ref(null)
|
||||
let chartInstance = null
|
||||
const fullwidth = window.screen.width
|
||||
|
||||
use([
|
||||
GraphChart,
|
||||
SVGRenderer,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
DataZoomComponent,
|
||||
])
|
||||
|
||||
async function getQuests() {
|
||||
try {
|
||||
const res = await api.get('/quests')
|
||||
return res.data
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const genOption = (nodes: any, edges: any) => ({
|
||||
renderer: 'svg',
|
||||
tooltip: {},
|
||||
animationDurationUpdate: 1500,
|
||||
animationEasingUpdate: 'quinticInOut',
|
||||
// dataZoom: [
|
||||
// {
|
||||
// type: 'slider', // This type means "slider data zoom" which provides a slider bar for zooming
|
||||
// orient: 'vertical', // This makes the data zoom component vertical
|
||||
// start: 0, // The starting percentage of the window out of the data extent
|
||||
// end: 100, // The ending percentage of the window out of the data extent
|
||||
// show: true,
|
||||
// },
|
||||
// ],
|
||||
series: [
|
||||
{
|
||||
type: 'graph',
|
||||
layout: 'none',
|
||||
symbol: () => {
|
||||
return NodeImage
|
||||
},
|
||||
symbolSize: [fullwidth / 13, fullwidth / 30],
|
||||
roam: 'move',
|
||||
zoom: 1.5,
|
||||
center: ['120%', '30%'],
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: fullwidth / 90,
|
||||
},
|
||||
edgeSymbol: ['pin', 'arrow'],
|
||||
edgeSymbolSize: [2, 15],
|
||||
edgeLabel: {
|
||||
fontSize: 25,
|
||||
},
|
||||
data: nodes,
|
||||
// links: [],
|
||||
links: edges,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: 'gray',
|
||||
opacity: 4,
|
||||
curveness: 0.08,
|
||||
},
|
||||
itemStyle: {
|
||||
color: (params: any) => {
|
||||
const {
|
||||
data: { completed, unlocked },
|
||||
} = params
|
||||
if (completed) {
|
||||
return '#00ff00'
|
||||
}
|
||||
if (unlocked) {
|
||||
return '#ADADB5'
|
||||
}
|
||||
return '#505050'
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
function getOption(quests: any, progress: any) {
|
||||
const dag = new DAG(quests)
|
||||
|
||||
const nodes = dag.getNodes().map((node) => ({
|
||||
id: node._id,
|
||||
name: node.title,
|
||||
y: marginX * node.index - (marginX * dag.getLayer(node._id)) / 2,
|
||||
x: marginY * node.layer * 2,
|
||||
completed: progress[node._id]?.completed || false,
|
||||
unlocked: node.requirements.every((req: any) => progress[req]?.completed),
|
||||
}))
|
||||
const lineAppearence = (node: any) => {
|
||||
try {
|
||||
const { unlocked } = node
|
||||
return {
|
||||
type: unlocked ? 'line' : 'dashed',
|
||||
color: unlocked ? '#ADADB5' : '#454552',
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
const edges = dag.getEdgesArray().map((edge: any) => ({
|
||||
source: edge[0],
|
||||
target: edge[1],
|
||||
lineStyle: lineAppearence(nodes.find((node) => node.id === edge[1])),
|
||||
}))
|
||||
return genOption(nodes, edges)
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
function initChart(option: any) {
|
||||
chartInstance = init(chartContainer.value)
|
||||
chartInstance.setOption(option)
|
||||
chartInstance.on('click', (params: any) => {
|
||||
const {
|
||||
data: { id, unlocked },
|
||||
} = params
|
||||
const svgWidth = ref(window.innerWidth)
|
||||
const svgHeight = ref(window.innerHeight)
|
||||
|
||||
if (unlocked) {
|
||||
router.push({ name: 'game', params: { questId: id } })
|
||||
} else {
|
||||
toast.warning('你還沒完成前一個關卡!')
|
||||
}
|
||||
})
|
||||
const marginX = 120
|
||||
const marginY = 120
|
||||
|
||||
const nodeWidth = 160
|
||||
const nodeHeight = 50
|
||||
|
||||
type Node = {
|
||||
id: string
|
||||
title: string
|
||||
x: number
|
||||
y: number
|
||||
index: number
|
||||
completed: boolean
|
||||
unlocked: boolean
|
||||
}
|
||||
|
||||
type Edge = {
|
||||
source: Node
|
||||
target: Node
|
||||
}
|
||||
|
||||
const graphData = ref<{ nodes: Node[]; edges: Edge[] }>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartY = ref(0)
|
||||
const offsetX = ref(0)
|
||||
const offsetY = ref(0)
|
||||
|
||||
function startDrag(event: MouseEvent) {
|
||||
isDragging.value = true
|
||||
dragStartX.value = event.clientX - offsetX.value
|
||||
dragStartY.value = event.clientY - offsetY.value
|
||||
}
|
||||
|
||||
function drag(event: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
offsetX.value = event.clientX - dragStartX.value
|
||||
offsetY.value = event.clientY - dragStartY.value
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
async function computeGraphData() {
|
||||
if (!store.progress) return
|
||||
|
||||
try {
|
||||
const quests = await getQuests()
|
||||
const dag = new DAG(quests)
|
||||
const nodesValues = dag.getNodes()
|
||||
|
||||
const nodes = nodesValues.map((node) => ({
|
||||
id: node._id,
|
||||
title: node.title,
|
||||
x: marginX * node.layer * 2,
|
||||
y:
|
||||
marginY * node.index -
|
||||
(marginX * dag.getLayer(node._id)) / 2 +
|
||||
svgHeight.value / 2,
|
||||
completed: store.progress[node._id]?.completed || false,
|
||||
unlocked: node.requirements.every(
|
||||
(req: string) => store.progress[req]?.completed
|
||||
),
|
||||
index: node.index,
|
||||
}))
|
||||
|
||||
const edges = dag
|
||||
.getEdgesArray()
|
||||
.map((edge) => ({
|
||||
source: nodes.find((n) => n.id === edge[0]),
|
||||
target: nodes.find((n) => n.id === edge[1]),
|
||||
}))
|
||||
.filter(
|
||||
(edge): edge is Edge =>
|
||||
edge.source !== undefined && edge.target !== undefined
|
||||
)
|
||||
|
||||
graphData.value = { nodes, edges }
|
||||
} catch (error) {
|
||||
console.error('Error computing graph data:', error)
|
||||
toast.error('Failed to load quest data')
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeClick(node: { id: string; unlocked: boolean }) {
|
||||
if (node.unlocked) {
|
||||
router.push({ name: 'game', params: { questId: node.id } })
|
||||
} else {
|
||||
toast.warning('你還沒完成前一個關卡!')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadUserProfile()
|
||||
const quests = await getQuests()
|
||||
const option = getOption(quests, store.progress)
|
||||
initChart(option)
|
||||
await computeGraphData()
|
||||
})
|
||||
|
||||
watch(() => store.progress, computeGraphData, { deep: true })
|
||||
|
||||
const curvedPath = computed(() => {
|
||||
return (source: Node, target: Node) => {
|
||||
// const dx = target.x - source.x
|
||||
// const dy = target.y - source.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}`
|
||||
}
|
||||
})
|
||||
|
||||
const nodeStyle = computed(() => {
|
||||
return (node: Node) => {
|
||||
if (node.completed) return 'completed'
|
||||
if (node.unlocked) return 'unlocked'
|
||||
return 'locked'
|
||||
}
|
||||
})
|
||||
|
||||
const edgeStyle = computed(() => {
|
||||
return (edge: Edge) => {
|
||||
return edge.target.unlocked ? 'unlocked' : 'locked'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -174,8 +156,94 @@ onMounted(async () => {
|
||||
>
|
||||
踏上你的Linux冒險之旅吧!
|
||||
</h1>
|
||||
<div class="flex flex-wrap absolute w-full h-full z-1">
|
||||
<div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
|
||||
</div>
|
||||
<svg
|
||||
:width="svgWidth"
|
||||
:height="svgHeight"
|
||||
class="absolute"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="endDrag"
|
||||
@mouseleave="endDrag"
|
||||
>
|
||||
<g :transform="`translate(${offsetX}, ${offsetY})`">
|
||||
<g
|
||||
v-for="edge in graphData.edges"
|
||||
:key="`${edge.source.id}-${edge.target.id}`"
|
||||
>
|
||||
<path
|
||||
:d="curvedPath(edge.source, edge.target)"
|
||||
:class="['edge', edgeStyle(edge)]"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
v-for="node in graphData.nodes"
|
||||
:key="node.id"
|
||||
@click="handleNodeClick(node)"
|
||||
>
|
||||
<rect
|
||||
:x="node.x - nodeWidth / 2"
|
||||
:y="node.y - nodeHeight / 2"
|
||||
:width="nodeWidth"
|
||||
:height="nodeHeight"
|
||||
rx="10"
|
||||
ry="10"
|
||||
:class="['node', nodeStyle(node)]"
|
||||
/>
|
||||
<text
|
||||
:x="node.x"
|
||||
:y="node.y"
|
||||
text-anchor="middle"
|
||||
alignment-baseline="middle"
|
||||
:class="['node-text', nodeStyle(node)]"
|
||||
font-size="18"
|
||||
>
|
||||
{{ node.title }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.node {
|
||||
&.completed {
|
||||
fill: #6cf76c;
|
||||
}
|
||||
&.unlocked {
|
||||
fill: #8c8c92;
|
||||
}
|
||||
&.locked {
|
||||
fill: #505050;
|
||||
}
|
||||
}
|
||||
|
||||
.node-text {
|
||||
&.completed {
|
||||
fill: #1d1d1d;
|
||||
}
|
||||
&.unlocked {
|
||||
fill: #ffffff;
|
||||
}
|
||||
&.locked {
|
||||
fill: #a0a0a0;
|
||||
}
|
||||
}
|
||||
|
||||
.edge {
|
||||
stroke-width: 3;
|
||||
fill: none;
|
||||
&.unlocked {
|
||||
stroke: #adadb5;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
&.locked {
|
||||
stroke: #454552;
|
||||
stroke-dasharray: 5, 5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,14 +12,14 @@ interface IProgress {
|
||||
export interface UserProfile {
|
||||
username: string
|
||||
email: string
|
||||
progress: Map<string, IProgress>
|
||||
progress: Record<string, IProgress>
|
||||
}
|
||||
|
||||
const useUserProfile = defineStore('userProfile', {
|
||||
state: (): UserProfile => ({
|
||||
username: '',
|
||||
email: '',
|
||||
progress: new Map<string, IProgress>(),
|
||||
progress: {},
|
||||
}),
|
||||
actions: {
|
||||
async loadUserProfile() {
|
||||
|
||||
@@ -43,3 +43,8 @@ export async function getActiveSession(
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getQuests(): Promise<IQuest[]> {
|
||||
const res = await api.get<IQuest[]>('/quests')
|
||||
return res.data
|
||||
}
|
||||
@@ -5,12 +5,12 @@ interface Node {
|
||||
|
||||
export default class DAG {
|
||||
private nodes = new Map()
|
||||
private edges = new Set()
|
||||
private edges = new Set<[string, string]>()
|
||||
private layers: number[] = []
|
||||
|
||||
constructor(nodes: Node[]) {
|
||||
this.nodes = new Map(nodes.map((node) => [node._id, node]))
|
||||
this.edges = new Set()
|
||||
this.edges = new Set<[string, string]>()
|
||||
this.nodes.forEach((_, id) => {
|
||||
this.setLayer(id)
|
||||
this.addEdges(id)
|
||||
@@ -42,7 +42,7 @@ export default class DAG {
|
||||
if (!node) {
|
||||
throw new Error(`Node ${id} not found`)
|
||||
}
|
||||
return this.layers[node.layer - 1]
|
||||
return this.layers[node.layer - 1] || 0
|
||||
}
|
||||
|
||||
private setLayer(id: string) {
|
||||
|
||||
@@ -3817,14 +3817,6 @@ ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
echarts@^5.4.3:
|
||||
version "5.5.0"
|
||||
resolved "https://registry.yarnpkg.com/echarts/-/echarts-5.5.0.tgz#c13945a7f3acdd67c134d8a9ac67e917830113ac"
|
||||
integrity sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw==
|
||||
dependencies:
|
||||
tslib "2.3.0"
|
||||
zrender "5.5.0"
|
||||
|
||||
ee-first@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||
@@ -7896,11 +7888,6 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
|
||||
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
|
||||
|
||||
tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||
@@ -8479,10 +8466,3 @@ zod@^3.23.8:
|
||||
version "3.23.8"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
||||
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
|
||||
|
||||
zrender@5.5.0:
|
||||
version "5.5.0"
|
||||
resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.5.0.tgz#54d0d6c4eda81a96d9f60a9cd74dc48ea026bc1e"
|
||||
integrity sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w==
|
||||
dependencies:
|
||||
tslib "2.3.0"
|
||||
Reference in new issue
Block a user