|
|
@@ -0,0 +1,354 @@
|
|
|
+<template>
|
|
|
+ <div id="HeatMap">
|
|
|
+ <div class="container">
|
|
|
+ <!-- 控制区:自定义阈值和差值选择框 -->
|
|
|
+ <div class="controls" style="margin-bottom: 20px;">
|
|
|
+ <label>
|
|
|
+ 停顿阈值:
|
|
|
+ <select v-model="customThreshold">
|
|
|
+ <option v-for="v in [1000,2000,3000,4000,5000]" :key="v" :value="v">{{ v }}ms</option>
|
|
|
+ </select>
|
|
|
+ </label>
|
|
|
+ <label style="margin-left: 20px;">
|
|
|
+ 区间差值:
|
|
|
+ <select v-model="customStep">
|
|
|
+ <option v-for="v in [500,1000,2000]" :key="v" :value="v">{{ v }}ms</option>
|
|
|
+ </select>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <!-- 文本和热度图显示区域 -->
|
|
|
+ <div class="text-container">
|
|
|
+ <div
|
|
|
+ v-for="(line, lineIndex) in formattedLines"
|
|
|
+ :key="lineIndex"
|
|
|
+ class="text-line"
|
|
|
+ >
|
|
|
+ <!-- 显示文本行 -->
|
|
|
+ <span
|
|
|
+ v-for="(char, charIndex) in line.chars"
|
|
|
+ :key="charIndex"
|
|
|
+ class="char"
|
|
|
+ >
|
|
|
+ {{ char }}
|
|
|
+ </span>
|
|
|
+ <!-- 显示热度图 -->
|
|
|
+ <div
|
|
|
+ class="heatmap"
|
|
|
+ :style="{ width: `${line.width}px` }"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ v-for="(item, itemIndex) in line.pauses"
|
|
|
+ :key="itemIndex"
|
|
|
+ class="heatmap-rect"
|
|
|
+ :style="{
|
|
|
+ backgroundColor: getColor(item.totalPauseTime),
|
|
|
+ left: `${item.position}px`,
|
|
|
+ width: `${heatmapRectWidth}px`,
|
|
|
+ height: `${heatmapRectHeight}px`
|
|
|
+ }"
|
|
|
+ @mouseover="showTooltip(item.totalPauseTime, $event)"
|
|
|
+ @mouseout="hideTooltip"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 颜色图例 -->
|
|
|
+ <div class="legend">
|
|
|
+ <div
|
|
|
+ v-for="(item, index) in colorLegend"
|
|
|
+ :key="index"
|
|
|
+ class="legend-item"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ class="legend-color"
|
|
|
+ :style="{ backgroundColor: item.color }"
|
|
|
+ />
|
|
|
+ <span>{{ item.label }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 提示框 -->
|
|
|
+ <div
|
|
|
+ v-if="tooltipVisible"
|
|
|
+ class="tooltip"
|
|
|
+ :style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
|
|
|
+ >
|
|
|
+ totalPauseTime = {{ tooltipText }}ms
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import {ref, computed, onMounted} from 'vue'
|
|
|
+import bevaviorApis from '@/apis/behavior'
|
|
|
+
|
|
|
+// 响应式数据 - 控制UI元素尺寸
|
|
|
+const charWidth = ref(12) // 普通字符宽度(像素)
|
|
|
+const spaceWidth = ref(8) // 空格字符宽度(像素)
|
|
|
+const heatmapRectWidth = ref(8) // 热度图矩形宽度(像素)
|
|
|
+const heatmapRectHeight = ref(12) // 热度图矩形高度(像素)
|
|
|
+const heatmapOffset = ref(5) // 热度图与文本的垂直偏移(像素)
|
|
|
+const lineHeight = ref(30) // 文本行高(像素)
|
|
|
+
|
|
|
+// 提示框相关数据
|
|
|
+const tooltipVisible = ref(false)
|
|
|
+const tooltipText = ref('')
|
|
|
+const tooltipX = ref(0)
|
|
|
+const tooltipY = ref(0)
|
|
|
+
|
|
|
+// 自定义停顿阈值和区间差值
|
|
|
+const customThreshold = ref(2000)
|
|
|
+const customStep = ref(1000)
|
|
|
+
|
|
|
+// 动态生成 colorLegend
|
|
|
+const colorLegend = computed(() => {
|
|
|
+ const arr = []
|
|
|
+ let start = customThreshold.value
|
|
|
+ let colorArr = ['#FFF6CC', '#FFD699', '#FFA64D', '#FF7519', '#CC2900']
|
|
|
+ for (let i = 0; i < colorArr.length - 1; i++) {
|
|
|
+ arr.push({
|
|
|
+ time: start + i * customStep.value,
|
|
|
+ color: colorArr[i],
|
|
|
+ label: i === 0
|
|
|
+ ? `≤${start}ms`
|
|
|
+ : `${start + (i - 1) * customStep.value}-${start + i * customStep.value}ms`
|
|
|
+ })
|
|
|
+ }
|
|
|
+ arr.push({
|
|
|
+ time: Infinity,
|
|
|
+ color: colorArr[colorArr.length - 1],
|
|
|
+ label: `≥${start + (colorArr.length - 2) * customStep.value}ms`
|
|
|
+ })
|
|
|
+ return arr
|
|
|
+})
|
|
|
+
|
|
|
+// 用于存储 events 数据
|
|
|
+const actionData = ref([])
|
|
|
+// 这里 studentId 和 assignmentId 可根据实际情况传递
|
|
|
+const studentId = 16
|
|
|
+const assignmentId = 7
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ try {
|
|
|
+ const res = await bevaviorApis.selectAllBehaviorRecord(studentId, assignmentId)
|
|
|
+ if (res.data && res.data.data && Array.isArray(res.data.data.events)) {
|
|
|
+ actionData.value = res.data.data.events
|
|
|
+ } else {
|
|
|
+ actionData.value = []
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ actionData.value = []
|
|
|
+ // 可加错误提示
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 计算属性 - 处理原始数据并格式化为多行显示
|
|
|
+const formattedLines = computed(() => {
|
|
|
+ // 计算每行最大宽度(适应窗口大小)
|
|
|
+ const maxLineWidth = Math.min(1000, window.innerWidth - 40)
|
|
|
+ // 使用Map存储每个位置的字符、第一次操作的startStamp、最后一次操作的startStamp和保留位置
|
|
|
+ const positionMap = new Map()
|
|
|
+ let lastStartStamp = 0;
|
|
|
+
|
|
|
+ actionData.value.forEach(item => {
|
|
|
+ // 只处理 insert、delete、copy 类型的数据条目
|
|
|
+ if (["insert", "delete", "copy"].includes(item.type)) {
|
|
|
+ const {retain, insert, startStamp} = item
|
|
|
+ // 删除操作显示为空格
|
|
|
+ const char = item.type === 'delete' ? ' ' : insert
|
|
|
+
|
|
|
+ if (positionMap.has(retain)) {
|
|
|
+ // 已有记录,更新字符和最后一次操作的startStamp
|
|
|
+ const record = positionMap.get(retain)
|
|
|
+ record.char = char
|
|
|
+ record.lastStartStamp = startStamp
|
|
|
+ } else {
|
|
|
+ // 新增记录
|
|
|
+ positionMap.set(retain, {
|
|
|
+ char,
|
|
|
+ firstStartStamp: lastStartStamp,
|
|
|
+ lastStartStamp: startStamp,
|
|
|
+ retain
|
|
|
+ })
|
|
|
+ }
|
|
|
+ lastStartStamp = startStamp;
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 计算每个位置的totalPauseTime
|
|
|
+ positionMap.forEach(record => {
|
|
|
+ record.totalPauseTime = record.lastStartStamp - record.firstStartStamp
|
|
|
+ })
|
|
|
+
|
|
|
+ // 将Map转换为数组并按位置排序
|
|
|
+ const finalPositions = Array.from(positionMap.values()).sort((a, b) => a.retain - b.retain)
|
|
|
+ const lines = []
|
|
|
+ // 当前行对象,存储字符、停顿信息和宽度
|
|
|
+ let currentLine = {chars: [], pauses: [], width: 0, retainOffset: 0}
|
|
|
+
|
|
|
+ // 处理每个字符,生成多行显示
|
|
|
+ finalPositions.forEach(item => {
|
|
|
+ // 处理单个字符的函数
|
|
|
+ const processCharacter = (char, isFirstChar = false) => {
|
|
|
+ // 计算字符宽度(空格和普通字符不同)
|
|
|
+ const charWidthVal = char === ' ' ? spaceWidth.value : charWidth.value
|
|
|
+ const nextLineWidth = currentLine.width + charWidthVal
|
|
|
+
|
|
|
+ // 如果添加字符后超过最大宽度且当前行已有内容,则换行
|
|
|
+ if (nextLineWidth > maxLineWidth && currentLine.chars.length > 0) {
|
|
|
+ lines.push(currentLine)
|
|
|
+ // 创建新行,计算保留位置偏移量
|
|
|
+ currentLine = {
|
|
|
+ chars: [],
|
|
|
+ pauses: [],
|
|
|
+ width: 0,
|
|
|
+ retainOffset: lines.reduce((sum, line) => sum + line.chars.length, 0)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加字符到当前行
|
|
|
+ currentLine.chars.push(char)
|
|
|
+ currentLine.width += charWidthVal
|
|
|
+
|
|
|
+ // 如果停顿时间超过自定义阈值且是字符的第一个字符,则记录停顿
|
|
|
+ if (item.totalPauseTime > customThreshold.value && isFirstChar) {
|
|
|
+ // 计算热度图矩形位置(字符中心偏左一点)
|
|
|
+ const position = currentLine.chars.length * charWidth.value - (charWidth.value / 1.2)
|
|
|
+ currentLine.pauses.push({
|
|
|
+ retain: item.retain,
|
|
|
+ totalPauseTime: item.totalPauseTime,
|
|
|
+ position
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理多字符插入(如复制粘贴的文本)
|
|
|
+ if (item.char && item.char.length > 1) {
|
|
|
+ item.char.split('').forEach((char, index) => {
|
|
|
+ // 只对第一个字符标记停顿
|
|
|
+ processCharacter(char, index === 0)
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ // 处理单字符插入
|
|
|
+ processCharacter(item.char, true)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 添加最后一行(如果有内容)
|
|
|
+ if (currentLine.chars.length > 0) {
|
|
|
+ lines.push(currentLine)
|
|
|
+ }
|
|
|
+
|
|
|
+ return lines
|
|
|
+})
|
|
|
+
|
|
|
+// 根据停顿时间获取对应的颜色
|
|
|
+const getColor = (pauseTime) => {
|
|
|
+ // 查找第一个满足 pauseTime <= item.time 的颜色项
|
|
|
+ const legend = colorLegend.value.find(item => pauseTime <= item.time)
|
|
|
+ // 返回找到的颜色,或默认最深的颜色
|
|
|
+ return legend?.color || colorLegend.value[colorLegend.value.length - 1].color
|
|
|
+}
|
|
|
+
|
|
|
+// 显示提示框
|
|
|
+const showTooltip = (totalPauseTime, event) => {
|
|
|
+ tooltipVisible.value = true
|
|
|
+ tooltipText.value = totalPauseTime
|
|
|
+ tooltipX.value = event.clientX + 10
|
|
|
+ tooltipY.value = event.clientY + 10
|
|
|
+}
|
|
|
+
|
|
|
+// 隐藏提示框
|
|
|
+const hideTooltip = () => {
|
|
|
+ tooltipVisible.value = false
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+#HeatMap {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center; /* 水平居中 */
|
|
|
+ align-items: center; /* 垂直居中 */
|
|
|
+ height: 90vh; /* 占满整个视口高度 */
|
|
|
+ width: 100%; /* 占满整个宽度 */
|
|
|
+}
|
|
|
+
|
|
|
+.container {
|
|
|
+ max-width: 1000px;
|
|
|
+ margin: 0 auto;
|
|
|
+ padding: 20px;
|
|
|
+ font-family: Arial, sans-serif;
|
|
|
+}
|
|
|
+
|
|
|
+.text-container {
|
|
|
+ position: relative;
|
|
|
+ margin-bottom: 30px;
|
|
|
+}
|
|
|
+
|
|
|
+.text-line {
|
|
|
+ position: relative;
|
|
|
+ margin-bottom: 25px;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.char {
|
|
|
+ display: inline-block;
|
|
|
+ width: v-bind('charWidth + "px"');
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.heatmap {
|
|
|
+ position: absolute;
|
|
|
+ bottom: -20px;
|
|
|
+ left: 0;
|
|
|
+ height: v-bind('heatmapRectHeight + "px"');
|
|
|
+}
|
|
|
+
|
|
|
+.heatmap-rect {
|
|
|
+ position: absolute;
|
|
|
+ border-radius: 2px;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.legend {
|
|
|
+ display: flex;
|
|
|
+ gap: 15px;
|
|
|
+ margin-top: 50px;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-color {
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ border-radius: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.tooltip {
|
|
|
+ position: absolute;
|
|
|
+ background-color: rgba(0, 0, 0, 0.8);
|
|
|
+ color: white;
|
|
|
+ padding: 5px;
|
|
|
+ border-radius: 3px;
|
|
|
+ font-size: 12px;
|
|
|
+ z-index: 10;
|
|
|
+}
|
|
|
+
|
|
|
+.controls {
|
|
|
+ font-size: 15px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+.controls label {
|
|
|
+ margin-right: 20px;
|
|
|
+}
|
|
|
+.controls select {
|
|
|
+ margin-left: 5px;
|
|
|
+ padding: 2px 8px;
|
|
|
+}
|
|
|
+</style>
|