add new lesson mode and started code refraction
This commit is contained in:
42
client/src/components/dashboard/DashboardWidget.vue
Normal file
42
client/src/components/dashboard/DashboardWidget.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.widget-card.rounded-xl.border-subtle.d-flex.flex-column(
|
||||
color="#1e1e24"
|
||||
flat
|
||||
)
|
||||
.d-flex.justify-space-between.align-center.mb-3(v-if="hasHeader")
|
||||
.d-flex.align-center
|
||||
v-icon.mr-2(
|
||||
v-if="icon"
|
||||
:color="iconColor"
|
||||
size="small"
|
||||
) {{ icon }}
|
||||
|
||||
div
|
||||
.text-subtitle-1.font-weight-bold.d-flex.align-center.text-white(
|
||||
style="line-height: 1.2"
|
||||
) {{ title }}
|
||||
.text-caption.text-grey(v-if="subtitle") {{ subtitle }}
|
||||
|
||||
div.d-flex.align-center
|
||||
slot(name="header-right")
|
||||
|
||||
.widget-content.flex-grow-1.d-flex.flex-column
|
||||
slot
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
iconColor: { type: String, default: 'secondary' },
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const hasHeader = computed(() => !!props.title || !!props.icon || !!props.subtitle);
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
@@ -1,37 +1,38 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle.d-flex.align-center.justify-space-between(color="#1e1e24")
|
||||
div
|
||||
.text-subtitle-2.text-grey {{ $t('stats.accuracy') }}
|
||||
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
|
||||
.text-caption.text-grey
|
||||
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }} / {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.accuracy')"
|
||||
icon="mdi-bullseye-arrow"
|
||||
)
|
||||
.d-flex.align-center.justify-space-between.flex-grow-1
|
||||
div
|
||||
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
|
||||
.text-caption.text-grey.mt-1
|
||||
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }}
|
||||
span.mx-1 /
|
||||
| {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
|
||||
|
||||
v-progress-circular(
|
||||
:model-value="accuracyPercent"
|
||||
color="#00cec9"
|
||||
size="100"
|
||||
width="10"
|
||||
bg-color="grey-darken-3"
|
||||
)
|
||||
v-icon(color="#00cec9" size="large") mdi-bullseye-arrow
|
||||
v-progress-circular(
|
||||
:model-value="accuracyPercent"
|
||||
color="primary"
|
||||
size="80"
|
||||
width="8"
|
||||
bg-color="grey-darken-3"
|
||||
)
|
||||
span.text-caption.font-weight-bold {{ accuracyPercent }}%
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
accuracy: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
correct: 0,
|
||||
total: 0
|
||||
})
|
||||
}
|
||||
accuracy: { type: Object, default: () => ({ correct: 0, total: 0 }) },
|
||||
});
|
||||
|
||||
const accuracyPercent = computed(() => {
|
||||
if (!props.accuracy || !props.accuracy.total) return 100;
|
||||
return Math.round((props.accuracy.correct / props.accuracy.total) * 100);
|
||||
if (!props.accuracy || !props.accuracy.total) return 100;
|
||||
return Math.round((props.accuracy.correct / props.accuracy.total) * 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-5.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
|
||||
v-card.widget-card.pa-5.d-flex.flex-column.flex-grow-1(flat)
|
||||
.d-flex.align-center.justify-space-between.mb-4
|
||||
.text-subtitle-1.font-weight-bold.d-flex.align-center
|
||||
v-icon(color="#ffeaa7" start size="small") mdi-chart-bar
|
||||
v-icon(color="secondary" start size="small") mdi-chart-bar
|
||||
| {{ $t('stats.srsDistribution') }}
|
||||
v-chip.font-weight-bold(
|
||||
size="x-small"
|
||||
@@ -10,7 +10,7 @@
|
||||
variant="tonal"
|
||||
) {{ totalItems }}
|
||||
|
||||
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.gap-2.flex-grow-1
|
||||
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.flex-grow-1
|
||||
.d-flex.flex-column.align-center.flex-grow-1.srs-column(
|
||||
v-for="lvl in 10"
|
||||
:key="lvl"
|
||||
@@ -20,11 +20,13 @@
|
||||
) {{ getCount(lvl) }}
|
||||
|
||||
.srs-track
|
||||
.srs-fill(:style="{\
|
||||
height: getBarHeight(getCount(lvl)) + '%',\
|
||||
background: getSRSColor(lvl),\
|
||||
boxShadow: getCount(lvl) > 0 ? `0 0 20px ${getSRSColor(lvl)}30` : 'none'\
|
||||
}")
|
||||
.srs-fill(
|
||||
:class="'bg-srs-' + lvl"
|
||||
:style="{\
|
||||
height: getBarHeight(getCount(lvl)) + '%',\
|
||||
'--shadow-color': 'var(--srs-' + lvl + ')'\
|
||||
}"
|
||||
)
|
||||
|
||||
.text-caption.text-grey-darken-1.font-weight-bold.mt-3(
|
||||
style="font-size: 10px !important;"
|
||||
@@ -32,41 +34,29 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
distribution: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
distribution: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
|
||||
|
||||
const totalItems = computed(
|
||||
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
|
||||
);
|
||||
const getCount = (lvl) => props.distribution?.[lvl] || 0;
|
||||
|
||||
const getBarHeight = (count) => {
|
||||
const max = Math.max(...Object.values(props.distribution || {}), 1);
|
||||
if (count > 0 && (count / max) * 100 < 4) return 4;
|
||||
return (count / max) * 100;
|
||||
};
|
||||
|
||||
const getSRSColor = (lvl) => {
|
||||
const colors = {
|
||||
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4',
|
||||
4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
|
||||
7: '#00cec9', 8: '#fd79a8', 9: '#e84393',
|
||||
10: '#ffd700'
|
||||
};
|
||||
return colors[lvl] || '#444';
|
||||
const max = Math.max(...Object.values(props.distribution || {}), 1);
|
||||
if (count > 0 && (count / max) * 100 < 4) return 4;
|
||||
return (count / max) * 100;
|
||||
};
|
||||
|
||||
const toRoman = (num) => {
|
||||
const lookup = {
|
||||
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V',
|
||||
6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X'
|
||||
};
|
||||
return lookup[num] || num;
|
||||
const lookup = {
|
||||
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V', 6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X',
|
||||
};
|
||||
return lookup[num] || num;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
|
||||
.text-subtitle-1.font-weight-bold.mb-3.d-flex.align-center
|
||||
v-icon(color="#ffeaa7" start) mdi-clock-outline
|
||||
| {{ $t('stats.next24') }}
|
||||
|
||||
DashboardWidget(
|
||||
:title="$t('stats.next24')"
|
||||
icon="mdi-clock-outline"
|
||||
)
|
||||
.forecast-list.flex-grow-1(v-if="hasUpcoming")
|
||||
div(v-for="(count, hour) in forecast" :key="hour")
|
||||
.d-flex.justify-space-between.align-center.mb-2.py-2.border-b-subtle(v-if="count > 0")
|
||||
span.text-body-2.text-grey-lighten-1
|
||||
| {{ hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour) }}
|
||||
v-chip.font-weight-bold(
|
||||
|
||||
v-chip.font-weight-bold.text-primary(
|
||||
size="small"
|
||||
color="#2f3542"
|
||||
style="color: #00cec9 !important;"
|
||||
color="surface-light"
|
||||
) {{ count }}
|
||||
|
||||
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
|
||||
@@ -20,22 +19,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
forecast: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
forecast: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const hasUpcoming = computed(() => {
|
||||
return props.forecast && props.forecast.some(c => c > 0);
|
||||
});
|
||||
const hasUpcoming = computed(() => props.forecast && props.forecast.some((c) => c > 0));
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped>
|
||||
.border-b-subtle {
|
||||
border-bottom: 1px solid rgb(255 255 255 / 5%);
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
|
||||
.d-flex.justify-space-between.align-center.mb-3
|
||||
.d-flex.align-center
|
||||
v-icon(color="#ff7675" start size="small") mdi-ghost
|
||||
div
|
||||
.text-subtitle-2.text-white {{ $t('stats.ghostTitle') }}
|
||||
.text-caption.text-grey {{ $t('stats.ghostSubtitle') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.ghostTitle')"
|
||||
:subtitle="$t('stats.ghostSubtitle')"
|
||||
icon="mdi-ghost"
|
||||
icon-color="var(--srs-1)"
|
||||
)
|
||||
.grid-wrapper(style="display: grid; grid-template-columns: minmax(0, 1fr); width: 100%;")
|
||||
.d-flex.gap-2.overflow-x-auto.pb-2(
|
||||
v-if="ghosts && ghosts.length > 0"
|
||||
style="scrollbar-width: thin; max-width: 100%;"
|
||||
)
|
||||
.ghost-card(
|
||||
v-for="ghost in ghosts"
|
||||
:key="ghost._id"
|
||||
style="min-width: 80px; flex-shrink: 0;"
|
||||
)
|
||||
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }}
|
||||
|
||||
.d-flex.justify-space-between.gap-2(v-if="ghosts && ghosts.length > 0")
|
||||
.ghost-card.flex-grow-1(v-for="ghost in ghosts" :key="ghost._id")
|
||||
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }}
|
||||
v-chip.font-weight-bold.text-black.w-100.justify-center(
|
||||
size="x-small"
|
||||
color="red-accent-2"
|
||||
variant="flat"
|
||||
) {{ ghost.accuracy }}%
|
||||
v-chip.font-weight-bold.w-100.justify-center(
|
||||
size="x-small"
|
||||
class="bg-srs-1 text-black"
|
||||
variant="flat"
|
||||
) {{ ghost.accuracy }}%
|
||||
|
||||
.text-center.py-2.text-caption.text-grey(v-else)
|
||||
| {{ $t('stats.noGhosts') }}
|
||||
.text-center.py-2.text-caption.text-grey(v-else)
|
||||
| {{ $t('stats.noGhosts') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
ghosts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
ghosts: { type: Array, default: () => [] },
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
<template lang="pug">
|
||||
v-card.widget-card.pa-5.rounded-xl.d-flex.flex-column.justify-center(color="#1e1e24" flat)
|
||||
.d-flex.justify-space-between.align-center.mb-2
|
||||
.d-flex.align-center
|
||||
v-icon(color="secondary" start size="small") mdi-trophy-outline
|
||||
span.text-subtitle-2.font-weight-bold {{ $t('stats.mastery') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.mastery')"
|
||||
icon="mdi-trophy-outline"
|
||||
)
|
||||
template(#header-right)
|
||||
.text-subtitle-2.text-white.font-weight-bold {{ masteryPercent }}%
|
||||
|
||||
v-progress-linear(
|
||||
:model-value="masteryPercent"
|
||||
color="primary"
|
||||
height="8"
|
||||
rounded
|
||||
bg-color="grey-darken-3"
|
||||
striped
|
||||
)
|
||||
.d-flex.flex-column.justify-center.flex-grow-1
|
||||
v-progress-linear(
|
||||
:model-value="masteryPercent"
|
||||
color="primary"
|
||||
height="8"
|
||||
rounded
|
||||
bg-color="grey-darken-3"
|
||||
striped
|
||||
)
|
||||
|
||||
.text-caption.text-medium-emphasis.mt-2.text-right
|
||||
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
|
||||
.text-caption.text-medium-emphasis.mt-2.text-right
|
||||
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
distribution: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
distribution: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
|
||||
|
||||
const masteredCount = computed(() => {
|
||||
const dist = props.distribution || {};
|
||||
return (dist[6] || 0);
|
||||
});
|
||||
|
||||
const masteryPercent = computed(() => {
|
||||
if (totalItems.value === 0) return 0;
|
||||
return Math.round((masteredCount.value / totalItems.value) * 100);
|
||||
});
|
||||
const totalItems = computed(
|
||||
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
|
||||
);
|
||||
const masteredCount = computed(
|
||||
() => (props.distribution || {})[6] || 0,
|
||||
);
|
||||
const masteryPercent = computed(
|
||||
() => (totalItems.value === 0 ? 0 : Math.round((masteredCount.value / totalItems.value) * 100)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template lang="pug">
|
||||
v-card.widget-card.pa-4.rounded-xl(color="#1e1e24" flat)
|
||||
.d-flex.flex-wrap.justify-space-between.align-center.mb-4.gap-2
|
||||
.text-subtitle-1.font-weight-bold.d-flex.align-center
|
||||
v-icon(color="secondary" start) mdi-calendar-check
|
||||
| {{ $t('stats.consistency') }}
|
||||
|
||||
DashboardWidget(
|
||||
:title="$t('stats.consistency')"
|
||||
icon="mdi-calendar-check"
|
||||
)
|
||||
template(#header-right)
|
||||
.legend-container
|
||||
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
|
||||
.legend-box.level-0
|
||||
@@ -22,10 +21,7 @@
|
||||
template(v-slot:activator="{ props }")
|
||||
.heatmap-cell(
|
||||
v-bind="props"
|
||||
:class="[\
|
||||
isToday(day.date) ? 'today-cell' : '',\
|
||||
getHeatmapClass(day.count)\
|
||||
]"
|
||||
:class="[isToday(day.date) ? 'today-cell' : '', getHeatmapClass(day.count)]"
|
||||
)
|
||||
.text-center
|
||||
.font-weight-bold {{ formatDate(day.date) }}
|
||||
@@ -33,53 +29,50 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { locale } = useI18n();
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const { locale } = useI18n();
|
||||
const props = defineProps({
|
||||
heatmapData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
heatmapData: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const weeks = computed(() => {
|
||||
const data = props.heatmapData || {};
|
||||
const w = [];
|
||||
const today = new Date();
|
||||
const data = props.heatmapData || {};
|
||||
const w = [];
|
||||
const today = new Date();
|
||||
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - (52 * 7));
|
||||
const dayOfWeek = startDate.getDay();
|
||||
startDate.setDate(startDate.getDate() - dayOfWeek);
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - (52 * 7));
|
||||
startDate.setDate(startDate.getDate() - startDate.getDay());
|
||||
|
||||
let currentWeek = [];
|
||||
for (let i = 0; i < 371; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
let currentWeek = [];
|
||||
for (let i = 0; i < 371; i += 1) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
count: data[dateStr] || 0
|
||||
});
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
count: data[dateStr] || 0,
|
||||
});
|
||||
|
||||
if (currentWeek.length === 7) {
|
||||
w.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
return w;
|
||||
if (currentWeek.length === 7) {
|
||||
w.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
return w;
|
||||
});
|
||||
|
||||
const getHeatmapClass = (count) => {
|
||||
if (count === 0) return 'level-0';
|
||||
if (count <= 5) return 'level-1';
|
||||
if (count <= 10) return 'level-2';
|
||||
if (count <= 20) return 'level-3';
|
||||
return 'level-4';
|
||||
if (count === 0) return 'level-0';
|
||||
if (count <= 5) return 'level-1';
|
||||
if (count <= 10) return 'level-2';
|
||||
if (count <= 20) return 'level-3';
|
||||
return 'level-4';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => new Date(dateStr).toLocaleDateString(locale.value, { month: 'short', day: 'numeric' });
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
|
||||
.d-flex.justify-space-between.align-start.mb-2
|
||||
div
|
||||
.text-subtitle-2.text-grey {{ $t('stats.streakTitle') }}
|
||||
.d-flex.align-center
|
||||
.text-h3.font-weight-bold.text-white.mr-2 {{ streak?.current || 0 }}
|
||||
.text-h6.text-grey {{ $t('stats.days') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.streakTitle')"
|
||||
icon="mdi-fire"
|
||||
icon-color="var(--srs-1)"
|
||||
)
|
||||
template(#header-right)
|
||||
v-tooltip(location="start" :text="shieldTooltip")
|
||||
template(v-slot:activator="{ props }")
|
||||
v-avatar.streak-shield-avatar(
|
||||
v-bind="props"
|
||||
size="32"
|
||||
:class="streak?.shield?.ready ? 'text-primary' : 'text-grey'"
|
||||
)
|
||||
v-icon(size="small")
|
||||
| {{ streak?.shield?.ready ? 'mdi-shield-check' : 'mdi-shield-off-outline' }}
|
||||
|
||||
.text-center
|
||||
v-tooltip(
|
||||
location="start"
|
||||
:text="streak?.shield?.ready ? $t('stats.shieldActive') : $t('stats.shieldCooldown', { n: streak?.shield?.cooldown })"
|
||||
.d-flex.flex-column.justify-space-between.flex-grow-1
|
||||
.d-flex.align-end.mb-3
|
||||
.text-h3.font-weight-bold.text-white.mr-2(style="line-height: 1")
|
||||
| {{ streak?.current || 0 }}
|
||||
.text-body-1.text-grey.mb-1 {{ $t('stats.days') }}
|
||||
|
||||
.d-flex.justify-space-between.align-center.px-1
|
||||
.d-flex.flex-column.align-center(
|
||||
v-for="(day, idx) in (streak?.history || [])"
|
||||
:key="idx"
|
||||
)
|
||||
template(v-slot:activator="{ props }")
|
||||
v-avatar(
|
||||
v-bind="props"
|
||||
size="48"
|
||||
:color="streak?.shield?.ready ? 'rgba(0, 206, 201, 0.1)' : 'rgba(255, 255, 255, 0.05)'"
|
||||
style="border: 1px solid;"
|
||||
:style="{ borderColor: streak?.shield?.ready ? '#00cec9' : '#555' }"
|
||||
)
|
||||
v-icon(:color="streak?.shield?.ready ? '#00cec9' : 'grey'")
|
||||
| {{ streak?.shield?.ready ? 'mdi-shield-check' : 'mdi-shield-off-outline' }}
|
||||
|
||||
.d-flex.justify-space-between.align-center.mt-2.px-1
|
||||
.d-flex.flex-column.align-center(
|
||||
v-for="(day, idx) in (streak?.history || [])"
|
||||
:key="idx"
|
||||
)
|
||||
.streak-dot.mb-1(:class="{ 'active': day.active }")
|
||||
v-icon(v-if="day.active" size="12" color="black") mdi-check
|
||||
.text-grey.text-uppercase(style="font-size: 10px;")
|
||||
| {{ getDayLabel(day.date) }}
|
||||
.streak-dot.mb-1(:class="{ 'active': day.active }")
|
||||
v-icon(v-if="day.active" size="12" color="black") mdi-check
|
||||
.text-grey.text-uppercase.streak-day-label
|
||||
| {{ getDayLabel(day.date) }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
streak: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
current: 0,
|
||||
history: [],
|
||||
shield: { ready: false, cooldown: 0 }
|
||||
})
|
||||
}
|
||||
streak: {
|
||||
type: Object,
|
||||
default: () => ({ current: 0, history: [], shield: { ready: false, cooldown: 0 } }),
|
||||
},
|
||||
});
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const shieldTooltip = computed(() => {
|
||||
const shield = props.streak?.shield;
|
||||
if (shield?.ready) return t('stats.shieldActive');
|
||||
return t('stats.shieldCooldown', { n: shield?.cooldown || 0 });
|
||||
});
|
||||
const { locale } = useI18n();
|
||||
|
||||
const getDayLabel = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,11 +3,24 @@
|
||||
.text-h2.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
|
||||
.text-h5.text-grey.mb-8 {{ $t('hero.subtitle') }}
|
||||
|
||||
.d-flex.justify-center.align-center.flex-column
|
||||
v-btn.text-h5.font-weight-bold.text-black.glow-btn(
|
||||
.d-flex.justify-center.align-center.flex-column.gap-4
|
||||
v-btn.text-h5.font-weight-bold.text-black.glow-btn.welcome-btn(
|
||||
v-if="lessonCount > 0"
|
||||
to="/lesson"
|
||||
rounded="xl"
|
||||
color="purple-accent-2"
|
||||
class="mb-3"
|
||||
)
|
||||
v-icon(size="32" start) mdi-school
|
||||
| {{ $t('hero.lessons') }}
|
||||
v-chip.ml-3.font-weight-bold(
|
||||
color="#1e1e24"
|
||||
variant="flat"
|
||||
size="default"
|
||||
style="color: white !important;"
|
||||
) {{ lessonCount }}
|
||||
v-btn.text-h5.font-weight-bold.text-black.glow-btn.welcome-btn(
|
||||
@click="$emit('start', 'shuffled')"
|
||||
height="80"
|
||||
width="280"
|
||||
rounded="xl"
|
||||
color="#00cec9"
|
||||
:disabled="queueLength === 0"
|
||||
@@ -38,39 +51,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
queueLength: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
hasLowerLevels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
lowerLevelCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
forecast: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
queueLength: { type: Number, required: true },
|
||||
lessonCount: { type: Number, default: 0 },
|
||||
hasLowerLevels: { type: Boolean, default: false },
|
||||
lowerLevelCount: { type: Number, default: 0 },
|
||||
forecast: { type: Object, default: () => ({}) },
|
||||
});
|
||||
defineEmits(['start']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const nextReviewTime = computed(() => {
|
||||
if (!props.forecast) return "a while";
|
||||
|
||||
const idx = props.forecast.findIndex(c => c > 0);
|
||||
|
||||
if (idx === -1) return "a while";
|
||||
return idx === 0 ? t('hero.now') : t('stats.inHours', { n: idx }, idx);
|
||||
if (!props.forecast) return 'a while';
|
||||
const idx = props.forecast.findIndex((c) => c > 0);
|
||||
if (idx === -1) return 'a while';
|
||||
return idx === 0 ? t('hero.now') : t('stats.inHours', { n: idx }, idx);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_buttons.scss" scoped></style>
|
||||
|
||||
@@ -1,247 +1,140 @@
|
||||
<template lang="pug">
|
||||
.canvas-container
|
||||
.loading-text(v-if="loading") {{ $t('review.loading') }}
|
||||
|
||||
.canvas-wrapper(ref="wrapper")
|
||||
canvas(
|
||||
ref="bgCanvas"
|
||||
:width="CANVAS_SIZE"
|
||||
:height="CANVAS_SIZE"
|
||||
)
|
||||
canvas(
|
||||
ref="snapCanvas"
|
||||
:width="CANVAS_SIZE"
|
||||
:height="CANVAS_SIZE"
|
||||
)
|
||||
.canvas-container(
|
||||
style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
|
||||
)
|
||||
.canvas-wrapper(
|
||||
ref="wrapper"
|
||||
:class="{ 'shake': isShaking }"
|
||||
:style="{ width: size + 'px', height: size + 'px', touchAction: 'none', userSelect: 'none', overscrollBehavior: 'none' }"
|
||||
)
|
||||
canvas(ref="bgCanvas")
|
||||
canvas(ref="hintCanvas")
|
||||
canvas(
|
||||
ref="drawCanvas"
|
||||
:width="CANVAS_SIZE"
|
||||
:height="CANVAS_SIZE"
|
||||
@mousedown="startDraw"
|
||||
@mousemove="draw"
|
||||
@mouseup="endDraw"
|
||||
@mouseleave="endDraw"
|
||||
@touchstart.prevent="startDraw"
|
||||
@touchmove.prevent="draw"
|
||||
@touchend.prevent="endDraw"
|
||||
style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerUp"
|
||||
@pointercancel="handlePointerUp"
|
||||
@touchstart.prevent.stop
|
||||
@touchmove.prevent.stop
|
||||
@touchend.prevent.stop
|
||||
@touchcancel.prevent.stop
|
||||
@contextmenu.prevent
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import {
|
||||
ref, onMounted, watch, onBeforeUnmount,
|
||||
} from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { KanjiController } from '@/utils/KanjiController';
|
||||
|
||||
const props = defineProps({
|
||||
char: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
const emit = defineEmits(['complete', 'mistake']);
|
||||
const store = useAppStore();
|
||||
|
||||
const KANJI_SIZE = 109;
|
||||
const CANVAS_SIZE = 300;
|
||||
const SCALE = CANVAS_SIZE / KANJI_SIZE;
|
||||
const props = defineProps({
|
||||
char: String,
|
||||
autoHint: Boolean,
|
||||
size: { type: Number, default: 300 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['complete', 'mistake']);
|
||||
|
||||
const wrapper = ref(null);
|
||||
const bgCanvas = ref(null);
|
||||
const snapCanvas = ref(null);
|
||||
const hintCanvas = ref(null);
|
||||
const drawCanvas = ref(null);
|
||||
let ctxBg, ctxSnap, ctxDraw;
|
||||
const isShaking = ref(false);
|
||||
|
||||
const kanjiPaths = ref([]);
|
||||
const currentStrokeIndex = ref(0);
|
||||
const failureCount = ref(0);
|
||||
const loading = ref(false);
|
||||
let isDrawing = false;
|
||||
let userPath = [];
|
||||
let controller = null;
|
||||
|
||||
function getPoint(e) {
|
||||
if (!drawCanvas.value) return { x: 0, y: 0 };
|
||||
const rect = drawCanvas.value.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
}
|
||||
|
||||
function handlePointerDown(e) {
|
||||
if (!controller) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
e.target.setPointerCapture(e.pointerId);
|
||||
|
||||
controller.startStroke(getPoint(e));
|
||||
}
|
||||
|
||||
function handlePointerMove(e) {
|
||||
if (!controller) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
controller.moveStroke(getPoint(e));
|
||||
}
|
||||
|
||||
function handlePointerUp(e) {
|
||||
if (!controller) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
e.target.releasePointerCapture(e.pointerId);
|
||||
|
||||
controller.endStroke();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initContexts();
|
||||
if (props.char) loadKanji(props.char);
|
||||
controller = new KanjiController({
|
||||
size: props.size,
|
||||
accuracy: store.drawingAccuracy,
|
||||
onComplete: () => emit('complete'),
|
||||
onMistake: (needsHint) => {
|
||||
isShaking.value = true;
|
||||
setTimeout(() => { isShaking.value = false; }, 400);
|
||||
emit('mistake', needsHint);
|
||||
},
|
||||
});
|
||||
|
||||
if (bgCanvas.value && hintCanvas.value && drawCanvas.value) {
|
||||
controller.mount({
|
||||
bg: bgCanvas.value,
|
||||
hint: hintCanvas.value,
|
||||
draw: drawCanvas.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.char) {
|
||||
controller.loadChar(props.char, props.autoHint);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
controller = null;
|
||||
});
|
||||
|
||||
watch(() => props.char, (newChar) => {
|
||||
if (newChar) loadKanji(newChar);
|
||||
if (controller && newChar) {
|
||||
controller.loadChar(newChar, props.autoHint);
|
||||
}
|
||||
});
|
||||
|
||||
function initContexts() {
|
||||
ctxBg = bgCanvas.value.getContext('2d');
|
||||
ctxSnap = snapCanvas.value.getContext('2d');
|
||||
ctxDraw = drawCanvas.value.getContext('2d');
|
||||
watch(() => props.autoHint, (shouldHint) => {
|
||||
if (!controller) return;
|
||||
if (shouldHint) controller.showHint();
|
||||
});
|
||||
|
||||
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.scale(SCALE, SCALE);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
});
|
||||
}
|
||||
watch(() => props.size, (newSize) => {
|
||||
if (controller) controller.resize(newSize);
|
||||
});
|
||||
|
||||
async function loadKanji(char) {
|
||||
reset();
|
||||
loading.value = true;
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
watch(() => store.drawingAccuracy, (newVal) => {
|
||||
if (controller) controller.setAccuracy(newVal);
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, "image/svg+xml");
|
||||
kanjiPaths.value = Array.from(doc.getElementsByTagName("path")).map(p => p.getAttribute("d"));
|
||||
|
||||
drawGuide();
|
||||
} catch (e) {
|
||||
console.error("Failed to load KanjiVG data", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
currentStrokeIndex.value = 0;
|
||||
failureCount.value = 0;
|
||||
kanjiPaths.value = [];
|
||||
|
||||
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
|
||||
ctx.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
function getCoords(e) {
|
||||
const rect = drawCanvas.value.getBoundingClientRect();
|
||||
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
return {
|
||||
x: (cx - rect.left) / SCALE,
|
||||
y: (cy - rect.top) / SCALE
|
||||
};
|
||||
}
|
||||
|
||||
function startDraw(e) {
|
||||
if (currentStrokeIndex.value >= kanjiPaths.value.length) return;
|
||||
isDrawing = true;
|
||||
userPath = [];
|
||||
|
||||
const p = getCoords(e);
|
||||
userPath.push(p);
|
||||
|
||||
ctxDraw.beginPath();
|
||||
ctxDraw.moveTo(p.x, p.y);
|
||||
ctxDraw.strokeStyle = '#ff7675';
|
||||
ctxDraw.lineWidth = 4;
|
||||
}
|
||||
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
const p = getCoords(e);
|
||||
userPath.push(p);
|
||||
ctxDraw.lineTo(p.x, p.y);
|
||||
ctxDraw.stroke();
|
||||
}
|
||||
|
||||
function endDraw() {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
const targetD = kanjiPaths.value[currentStrokeIndex.value];
|
||||
|
||||
if (checkMatch(userPath, targetD)) {
|
||||
ctxSnap.strokeStyle = '#00cec9';
|
||||
ctxSnap.lineWidth = 4;
|
||||
ctxSnap.stroke(new Path2D(targetD));
|
||||
|
||||
currentStrokeIndex.value++;
|
||||
failureCount.value = 0;
|
||||
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
|
||||
|
||||
if (currentStrokeIndex.value >= kanjiPaths.value.length) {
|
||||
emit('complete');
|
||||
} else {
|
||||
drawGuide();
|
||||
}
|
||||
} else {
|
||||
failureCount.value++;
|
||||
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
|
||||
|
||||
if (failureCount.value >= 3) {
|
||||
drawGuide(true);
|
||||
emit('mistake', true);
|
||||
} else {
|
||||
emit('mistake', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkMatch(userPts, targetD) {
|
||||
if (userPts.length < 5) return false;
|
||||
|
||||
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
tempPath.setAttribute("d", targetD);
|
||||
const len = tempPath.getTotalLength();
|
||||
const targetEnd = tempPath.getPointAtLength(len);
|
||||
const userEnd = userPts[userPts.length - 1];
|
||||
|
||||
const threshold = store.drawingAccuracy || 10;
|
||||
|
||||
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||||
if (dist(userEnd, targetEnd) > threshold * 3.0) return false;
|
||||
|
||||
let totalError = 0;
|
||||
const samples = 10;
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const pt = tempPath.getPointAtLength((i / samples) * len);
|
||||
let min = Infinity;
|
||||
for (let p of userPts) min = Math.min(min, dist(pt, p));
|
||||
totalError += min;
|
||||
}
|
||||
return (totalError / (samples + 1)) < threshold;
|
||||
}
|
||||
|
||||
function drawGuide(showHint = false) {
|
||||
ctxBg.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
|
||||
|
||||
if (!showHint) return;
|
||||
|
||||
const d = kanjiPaths.value[currentStrokeIndex.value];
|
||||
if (!d) return;
|
||||
|
||||
ctxBg.strokeStyle = '#57606f';
|
||||
ctxBg.lineWidth = 3;
|
||||
ctxBg.setLineDash([5, 5]);
|
||||
ctxBg.stroke(new Path2D(d));
|
||||
ctxBg.setLineDash([]);
|
||||
|
||||
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
tempPath.setAttribute("d", d);
|
||||
const len = tempPath.getTotalLength();
|
||||
const mid = tempPath.getPointAtLength(len / 2);
|
||||
const prev = tempPath.getPointAtLength(Math.max(0, (len / 2) - 1));
|
||||
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x);
|
||||
|
||||
ctxBg.save();
|
||||
ctxBg.translate(mid.x, mid.y);
|
||||
ctxBg.rotate(angle);
|
||||
|
||||
ctxBg.strokeStyle = 'rgba(255, 234, 167, 0.7)';
|
||||
ctxBg.lineWidth = 2;
|
||||
ctxBg.lineCap = 'round';
|
||||
ctxBg.lineJoin = 'round';
|
||||
|
||||
ctxBg.beginPath();
|
||||
ctxBg.moveTo(-7, 0);
|
||||
ctxBg.lineTo(2, 0);
|
||||
ctxBg.moveTo(-1, -3);
|
||||
ctxBg.lineTo(2, 0);
|
||||
ctxBg.lineTo(-1, 3);
|
||||
ctxBg.stroke();
|
||||
|
||||
ctxBg.restore();
|
||||
}
|
||||
|
||||
defineExpose({ drawGuide });
|
||||
defineExpose({
|
||||
reset: () => controller?.reset(),
|
||||
showHint: () => controller?.showHint(),
|
||||
drawGuide: () => controller?.showHint(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>
|
||||
|
||||
@@ -1,138 +1,151 @@
|
||||
<template lang="pug">
|
||||
.svg-container(:class="{ loading: loading }")
|
||||
.svg-container(
|
||||
:class="{ 'canvas-mode': mode === 'animate', 'hero-mode': mode === 'hero' }"
|
||||
)
|
||||
svg.kanji-svg(
|
||||
v-if="!loading"
|
||||
v-show="!loading"
|
||||
viewBox="0 0 109 109"
|
||||
width="100%"
|
||||
height="100%"
|
||||
)
|
||||
g(v-if="mode === 'animate'")
|
||||
path.stroke-ghost(
|
||||
v-for="(stroke, i) in strokes"
|
||||
:key="'ghost-'+i"
|
||||
:d="stroke.d"
|
||||
)
|
||||
|
||||
g(v-for="(stroke, i) in strokes" :key="i")
|
||||
path.stroke-path(
|
||||
:d="stroke.d"
|
||||
:class="{\
|
||||
'animating': isPlaying && currentStrokeIdx === i,\
|
||||
'hidden': isPlaying && currentStrokeIdx < i,\
|
||||
'drawn': isPlaying && currentStrokeIdx > i\
|
||||
}"
|
||||
:style="{\
|
||||
'--len': stroke.len,\
|
||||
'--duration': (stroke.len * 0.02) + 's'\
|
||||
}"
|
||||
:class="getStrokeClass(i)"
|
||||
:style="getStrokeStyle(stroke)"
|
||||
)
|
||||
|
||||
g(v-show="!isPlaying || currentStrokeIdx > i")
|
||||
circle.stroke-start-circle(
|
||||
v-if="stroke.start"
|
||||
:cx="stroke.start.x"
|
||||
:cy="stroke.start.y"
|
||||
r="3.5"
|
||||
)
|
||||
g(v-if="mode === 'animate' && (!isPlaying || currentStrokeIdx > -1)")
|
||||
g(v-for="(stroke, i) in strokes" :key="'anno-'+i")
|
||||
g(v-show="!isPlaying || currentStrokeIdx >= i")
|
||||
path.stroke-arrow-line(
|
||||
v-if="isPlaying && currentStrokeIdx === i && stroke.arrow"
|
||||
d="M -7 0 L 2 0 M -1 -3 L 2 0 L -1 3"
|
||||
:transform="getArrowTransform(stroke.arrow)"
|
||||
)
|
||||
|
||||
text.stroke-number(
|
||||
v-if="stroke.start"
|
||||
:x="stroke.start.x"
|
||||
:y="stroke.start.y + 0.5"
|
||||
) {{ i + 1 }}
|
||||
|
||||
path.stroke-arrow-line(
|
||||
v-if="stroke.arrow"
|
||||
d="M -7 0 L 2 0 M -1 -3 L 2 0 L -1 3"
|
||||
:transform="`translate(${stroke.arrow.x}, ${stroke.arrow.y}) rotate(${stroke.arrow.angle})`"
|
||||
)
|
||||
|
||||
.loading-spinner(v-else) {{ $t('review.loading') }}
|
||||
g.stroke-badge-group(
|
||||
v-if="stroke.start"
|
||||
:transform="`translate(${stroke.start.x}, ${stroke.start.y})`"
|
||||
)
|
||||
circle.stroke-badge-bg(r="4")
|
||||
text.stroke-badge-text(
|
||||
dy="0.5"
|
||||
) {{ i + 1 }}
|
||||
|
||||
button.play-btn(
|
||||
v-if="!loading && !isPlaying"
|
||||
@click="playAnimation"
|
||||
v-if="mode === 'animate' && !loading && !isPlaying"
|
||||
@click.stop="playAnimation"
|
||||
)
|
||||
svg(viewBox="0 0 24 24" fill="currentColor")
|
||||
path(d="M8 5v14l11-7z")
|
||||
path(d="M8,5.14V19.14L19,12.14L8,5.14Z")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
char: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
char: { type: String, required: true },
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'animate',
|
||||
validator: (v) => ['hero', 'animate'].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
const strokes = ref([]);
|
||||
const loading = ref(true);
|
||||
const isPlaying = ref(false);
|
||||
const currentStrokeIdx = ref(-1);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.char) loadData(props.char);
|
||||
});
|
||||
|
||||
watch(() => props.char, (newChar) => {
|
||||
if (newChar) loadData(newChar);
|
||||
});
|
||||
|
||||
async function loadData(char) {
|
||||
loading.value = true;
|
||||
isPlaying.value = false;
|
||||
strokes.value = [];
|
||||
loading.value = true;
|
||||
isPlaying.value = false;
|
||||
strokes.value = [];
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
try {
|
||||
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, "image/svg+xml");
|
||||
try {
|
||||
const baseUrl = 'https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji';
|
||||
const res = await fetch(`${baseUrl}/${hex}.svg`);
|
||||
|
||||
const rawPaths = Array.from(doc.getElementsByTagName("path")).map(p => p.getAttribute("d"));
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, 'image/svg+xml');
|
||||
const rawPaths = Array.from(doc.getElementsByTagName('path')).map((p) => p.getAttribute('d'));
|
||||
|
||||
strokes.value = rawPaths.map(d => {
|
||||
const data = { d, start: null, arrow: null, len: 0 };
|
||||
strokes.value = rawPaths.map((d) => {
|
||||
const data = {
|
||||
d, start: null, arrow: null, len: 0, duration: 0,
|
||||
};
|
||||
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
tempPath.setAttribute('d', d);
|
||||
try { data.len = tempPath.getTotalLength(); } catch (e) { data.len = 100; }
|
||||
data.duration = Math.floor(data.len * 20);
|
||||
|
||||
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
tempPath.setAttribute("d", d);
|
||||
try {
|
||||
data.len = tempPath.getTotalLength();
|
||||
} catch (e) { data.len = 100; }
|
||||
const startMatch = d.match(/[Mm]\s*([\d.]+)[,\s]([\d.]+)/);
|
||||
if (startMatch) data.start = { x: parseFloat(startMatch[1]), y: parseFloat(startMatch[2]) };
|
||||
|
||||
const startMatch = d.match(/[Mm]\s*([\d.]+)[,\s]([\d.]+)/);
|
||||
if (startMatch) {
|
||||
data.start = { x: parseFloat(startMatch[1]), y: parseFloat(startMatch[2]) };
|
||||
}
|
||||
try {
|
||||
const mid = tempPath.getPointAtLength(data.len / 2);
|
||||
const prev = tempPath.getPointAtLength(Math.max(0, (data.len / 2) - 1));
|
||||
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x) * (180 / Math.PI);
|
||||
data.arrow = { x: mid.x, y: mid.y, angle };
|
||||
} catch (e) { console.error(e); }
|
||||
return data;
|
||||
});
|
||||
} catch (e) { console.error(e); } finally { loading.value = false; }
|
||||
}
|
||||
|
||||
try {
|
||||
const mid = tempPath.getPointAtLength(data.len / 2);
|
||||
const prev = tempPath.getPointAtLength(Math.max(0, (data.len / 2) - 1));
|
||||
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x) * (180 / Math.PI);
|
||||
data.arrow = { x: mid.x, y: mid.y, angle };
|
||||
} catch (e) { console.error(e) }
|
||||
function getArrowTransform(arrow) {
|
||||
if (!arrow) return '';
|
||||
return `translate(${arrow.x}, ${arrow.y}) rotate(${arrow.angle})`;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
function getStrokeClass(i) {
|
||||
if (props.mode === 'hero') return 'drawn';
|
||||
|
||||
} catch (e) {
|
||||
console.error("SVG Load Failed", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
if (isPlaying.value) {
|
||||
if (currentStrokeIdx.value === i) return 'animating';
|
||||
if (currentStrokeIdx.value > i) return 'drawn';
|
||||
return 'hidden';
|
||||
}
|
||||
return 'drawn';
|
||||
}
|
||||
|
||||
function getStrokeStyle(stroke) {
|
||||
if (props.mode === 'hero') return {};
|
||||
return { '--len': stroke.len, '--duration': `${stroke.duration}ms` };
|
||||
}
|
||||
|
||||
async function playAnimation() {
|
||||
if (isPlaying.value) return;
|
||||
isPlaying.value = true;
|
||||
currentStrokeIdx.value = -1;
|
||||
if (isPlaying.value) return;
|
||||
isPlaying.value = true;
|
||||
currentStrokeIdx.value = -1;
|
||||
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
await new Promise((r) => { setTimeout(r, 200); });
|
||||
|
||||
for (let i = 0; i < strokes.value.length; i++) {
|
||||
currentStrokeIdx.value = i;
|
||||
const duration = strokes.value[i].len * 20;
|
||||
await new Promise(r => setTimeout(r, duration + 100));
|
||||
}
|
||||
for (let i = 0; i < strokes.value.length; i += 1) {
|
||||
currentStrokeIdx.value = i;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => { setTimeout(r, strokes.value[i].duration); });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => { setTimeout(r, 100); });
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
isPlaying.value = false;
|
||||
await new Promise((r) => { setTimeout(r, 500); });
|
||||
isPlaying.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ playAnimation });
|
||||
onMounted(() => { if (props.char) loadData(props.char); });
|
||||
watch(() => props.char, (n) => { if (n) loadData(n); });
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>
|
||||
|
||||
Reference in New Issue
Block a user