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",
|
"@types/dompurify": "^3.0.5",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"dompurify": "^3.0.6",
|
"dompurify": "^3.0.6",
|
||||||
"echarts": "^5.4.3",
|
|
||||||
"marked": "^10.0.0",
|
"marked": "^10.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"socket.io-client": "^4.7.1",
|
"socket.io-client": "^4.7.1",
|
||||||
|
|||||||
+216
-148
@@ -1,163 +1,145 @@
|
|||||||
<!-- eslint-disable no-console -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toastification'
|
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 { DAG } from '@linux-odyssey/utils'
|
||||||
import api from '../utils/api'
|
import { getQuests } from '../utils/api'
|
||||||
import { NodeImage } from '../img/svg.js'
|
|
||||||
import useUserProfile from '../store/userProfile'
|
import useUserProfile from '../store/userProfile'
|
||||||
|
|
||||||
const store = useUserProfile()
|
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 router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
function initChart(option: any) {
|
const svgWidth = ref(window.innerWidth)
|
||||||
chartInstance = init(chartContainer.value)
|
const svgHeight = ref(window.innerHeight)
|
||||||
chartInstance.setOption(option)
|
|
||||||
chartInstance.on('click', (params: any) => {
|
|
||||||
const {
|
|
||||||
data: { id, unlocked },
|
|
||||||
} = params
|
|
||||||
|
|
||||||
if (unlocked) {
|
const marginX = 120
|
||||||
router.push({ name: 'game', params: { questId: id } })
|
const marginY = 120
|
||||||
} else {
|
|
||||||
toast.warning('你還沒完成前一個關卡!')
|
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 () => {
|
onMounted(async () => {
|
||||||
await store.loadUserProfile()
|
await store.loadUserProfile()
|
||||||
const quests = await getQuests()
|
await computeGraphData()
|
||||||
const option = getOption(quests, store.progress)
|
})
|
||||||
initChart(option)
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -174,8 +156,94 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
踏上你的Linux冒險之旅吧!
|
踏上你的Linux冒險之旅吧!
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex flex-wrap absolute w-full h-full z-1">
|
<svg
|
||||||
<div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
|
:width="svgWidth"
|
||||||
</div>
|
: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>
|
</div>
|
||||||
</template>
|
</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 {
|
export interface UserProfile {
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
progress: Map<string, IProgress>
|
progress: Record<string, IProgress>
|
||||||
}
|
}
|
||||||
|
|
||||||
const useUserProfile = defineStore('userProfile', {
|
const useUserProfile = defineStore('userProfile', {
|
||||||
state: (): UserProfile => ({
|
state: (): UserProfile => ({
|
||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
progress: new Map<string, IProgress>(),
|
progress: {},
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async loadUserProfile() {
|
async loadUserProfile() {
|
||||||
|
|||||||
@@ -43,3 +43,8 @@ export async function getActiveSession(
|
|||||||
})
|
})
|
||||||
return res.data
|
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 {
|
export default class DAG {
|
||||||
private nodes = new Map()
|
private nodes = new Map()
|
||||||
private edges = new Set()
|
private edges = new Set<[string, string]>()
|
||||||
private layers: number[] = []
|
private layers: number[] = []
|
||||||
|
|
||||||
constructor(nodes: Node[]) {
|
constructor(nodes: Node[]) {
|
||||||
this.nodes = new Map(nodes.map((node) => [node._id, 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.nodes.forEach((_, id) => {
|
||||||
this.setLayer(id)
|
this.setLayer(id)
|
||||||
this.addEdges(id)
|
this.addEdges(id)
|
||||||
@@ -42,7 +42,7 @@ export default class DAG {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
throw new Error(`Node ${id} not found`)
|
throw new Error(`Node ${id} not found`)
|
||||||
}
|
}
|
||||||
return this.layers[node.layer - 1]
|
return this.layers[node.layer - 1] || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private setLayer(id: string) {
|
private setLayer(id: string) {
|
||||||
|
|||||||
@@ -3817,14 +3817,6 @@ ecdsa-sig-formatter@1.0.11:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "^5.0.1"
|
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:
|
ee-first@1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
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"
|
minimist "^1.2.6"
|
||||||
strip-bom "^3.0.0"
|
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:
|
tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2:
|
||||||
version "2.6.2"
|
version "2.6.2"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||||
@@ -8479,10 +8466,3 @@ zod@^3.23.8:
|
|||||||
version "3.23.8"
|
version "3.23.8"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
||||||
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
|
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