|
|
|
|
@ -0,0 +1,992 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div class="mark-container">
|
|
|
|
|
<!-- 左侧区域 -->
|
|
|
|
|
<el-aside class="left-container" width="300px">
|
|
|
|
|
<br/>
|
|
|
|
|
<el-select
|
|
|
|
|
v-model="selectedProjectId"
|
|
|
|
|
placeholder="请选择项目"
|
|
|
|
|
@change="handleProjectChange"
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
>
|
|
|
|
|
<el-option
|
|
|
|
|
v-for="project in projectList"
|
|
|
|
|
:key="project.id"
|
|
|
|
|
:label="project.name"
|
|
|
|
|
:value="project.id"
|
|
|
|
|
/>
|
|
|
|
|
</el-select>
|
|
|
|
|
<el-collapse v-model="activeLeftPanels" accordion>
|
|
|
|
|
<!-- 项目信息面板 -->
|
|
|
|
|
<el-collapse-item title="项目信息" name="projectInfo">
|
|
|
|
|
<div v-if="currentProject">
|
|
|
|
|
<el-descriptions :column="1" size="small">
|
|
|
|
|
<el-descriptions-item label="项目名称">{{ currentProject.name }}</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="项目描述">{{ currentProject.description }}</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentProject.createTime)}}</el-descriptions-item>
|
|
|
|
|
</el-descriptions>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else>
|
|
|
|
|
<el-empty description="请选择项目" :image-size="60" />
|
|
|
|
|
</div>
|
|
|
|
|
</el-collapse-item>
|
|
|
|
|
|
|
|
|
|
<!-- 标注类型面板 -->
|
|
|
|
|
<el-collapse-item title="标注类型" name="types">
|
|
|
|
|
<div class="type-header">
|
|
|
|
|
<el-button type="primary" size="small" @click="showAddTypeDialog">
|
|
|
|
|
<Icon icon="ep:plus" /> 新增
|
|
|
|
|
</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
<el-scrollbar class="left-scrollbar">
|
|
|
|
|
<el-table :data="annotationTypes" style="width: 100%">
|
|
|
|
|
<el-table-column label="类型" prop="name" />
|
|
|
|
|
<el-table-column label="颜色" width="80">
|
|
|
|
|
<template #default="scope">
|
|
|
|
|
<div class="color-circle" :style="{ backgroundColor: scope.row.color }"></div>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="操作" width="80">
|
|
|
|
|
<template #default="scope">
|
|
|
|
|
<el-button link type="danger" @click="deleteAnnotationType(scope.row.id)">删除</el-button>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
</el-scrollbar>
|
|
|
|
|
</el-collapse-item>
|
|
|
|
|
|
|
|
|
|
<!-- 标注结果面板 -->
|
|
|
|
|
<!-- 修改标注结果面板 -->
|
|
|
|
|
<el-collapse-item title="标注结果" name="results">
|
|
|
|
|
<el-scrollbar class="left-scrollbar">
|
|
|
|
|
<el-table :data="annotations" style="width: 100%" @row-click="selectAnnotation">
|
|
|
|
|
<el-table-column label="类型设置" width="120">
|
|
|
|
|
<template #default="scope">
|
|
|
|
|
<el-select
|
|
|
|
|
v-model="scope.row.classId"
|
|
|
|
|
placeholder="选择类型"
|
|
|
|
|
size="small"
|
|
|
|
|
@change="(value) => setAnnotationType(scope.$index, value)"
|
|
|
|
|
>
|
|
|
|
|
<el-option
|
|
|
|
|
v-for="type in annotationTypes"
|
|
|
|
|
:key="type.id"
|
|
|
|
|
:label="type.name"
|
|
|
|
|
:value="type.id"
|
|
|
|
|
/>
|
|
|
|
|
</el-select>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<!-- 修改标注结果面板中的属性引用 -->
|
|
|
|
|
<el-table-column label="坐标信息" prop="coordinates">
|
|
|
|
|
<template #default="scope">
|
|
|
|
|
<div class="coordinates-info">
|
|
|
|
|
<div v-if="scope.row.centerX !== undefined">
|
|
|
|
|
中心: ({{ Math.round(scope.row.centerX) }}, {{ Math.round(scope.row.centerY) }})
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="scope.row.width !== undefined">
|
|
|
|
|
尺寸: {{ Math.round(scope.row.width) }} x {{ Math.round(scope.row.height) }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="操作" width="80">
|
|
|
|
|
<template #default="scope">
|
|
|
|
|
<el-button link type="danger" @click="deleteAnnotation(scope.row, scope.$index)">删除</el-button>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
</el-scrollbar>
|
|
|
|
|
</el-collapse-item>
|
|
|
|
|
</el-collapse>
|
|
|
|
|
</el-aside>
|
|
|
|
|
|
|
|
|
|
<!-- 中间区域:图片标注区域 -->
|
|
|
|
|
<el-main class="center-container">
|
|
|
|
|
<div ref="annotatorContainer" class="annotator-wrapper">
|
|
|
|
|
<img
|
|
|
|
|
v-if="currentImage"
|
|
|
|
|
:src="currentImage.path"
|
|
|
|
|
:alt="currentImage.name"
|
|
|
|
|
class="annotator-image"
|
|
|
|
|
ref="annotatorImage"
|
|
|
|
|
/>
|
|
|
|
|
<el-empty v-else description="请选择图片进行标注" />
|
|
|
|
|
</div>
|
|
|
|
|
</el-main>
|
|
|
|
|
<!-- 右侧区域 -->
|
|
|
|
|
<el-aside class="right-container" width="300px">
|
|
|
|
|
<el-collapse v-model="activeRightPanels" accordion>
|
|
|
|
|
<!-- 图片选择面板 -->
|
|
|
|
|
<el-collapse-item title="图片选择" name="images">
|
|
|
|
|
<el-scrollbar class="image-scrollbar">
|
|
|
|
|
<div class="image-grid">
|
|
|
|
|
<div
|
|
|
|
|
v-for="image in imageList"
|
|
|
|
|
:key="image.id"
|
|
|
|
|
class="image-item"
|
|
|
|
|
:class="{ active: currentImage && currentImage.id === image.id }"
|
|
|
|
|
@click="selectImage(image)"
|
|
|
|
|
>
|
|
|
|
|
<div class="image-wrapper">
|
|
|
|
|
<img
|
|
|
|
|
:src="image.path"
|
|
|
|
|
:alt="image.name"
|
|
|
|
|
class="thumbnail"
|
|
|
|
|
/>
|
|
|
|
|
<!-- 添加状态指示器 -->
|
|
|
|
|
<div v-if="image.status === 1" class="status-indicator">
|
|
|
|
|
<Icon icon="ep:check" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</el-scrollbar>
|
|
|
|
|
</el-collapse-item>
|
|
|
|
|
</el-collapse>
|
|
|
|
|
</el-aside>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 添加类型对话框 -->
|
|
|
|
|
<el-dialog v-model="addTypeDialogVisible" title="添加标注类型" width="400px">
|
|
|
|
|
<el-form :model="newTypeForm" ref="typeFormRef" label-width="80px">
|
|
|
|
|
<el-form-item label="类型名称" prop="name" :rules="[{ required: true, message: '请输入类型名称', trigger: 'blur' }]">
|
|
|
|
|
<el-input v-model="newTypeForm.name" placeholder="请输入类型名称" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="颜色" prop="color" :rules="[{ required: true, message: '请选择颜色', trigger: 'blur' }]">
|
|
|
|
|
<el-color-picker v-model="newTypeForm.color" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
</el-form>
|
|
|
|
|
<template #footer>
|
|
|
|
|
<el-button @click="addTypeDialogVisible = false">取消</el-button>
|
|
|
|
|
<el-button type="primary" @click="addAnnotationType">确定</el-button>
|
|
|
|
|
</template>
|
|
|
|
|
</el-dialog>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { ref, reactive, onMounted } from 'vue'
|
|
|
|
|
import { MarkApi } from '@/api/annotation/mark'
|
|
|
|
|
import { createImageAnnotator } from '@annotorious/annotorious';
|
|
|
|
|
// 在响应式数据部分添加默认类型ID
|
|
|
|
|
const defaultTypeId = ref<number | null>(null);
|
|
|
|
|
|
|
|
|
|
// Import essential CSS styles
|
|
|
|
|
import '@annotorious/annotorious/annotorious.css';
|
|
|
|
|
|
|
|
|
|
// 在 script setup 中添加颜色生成相关函数
|
|
|
|
|
const usedColors: string[] = [];
|
|
|
|
|
// 在响应式数据部分添加
|
|
|
|
|
let annoInstance = null
|
|
|
|
|
|
|
|
|
|
// 修改初始化 annotorious 的方法,添加更多事件监听器
|
|
|
|
|
const initAnnotorious = async () => {
|
|
|
|
|
await nextTick();
|
|
|
|
|
if (currentImage.value && annotatorImage.value) {
|
|
|
|
|
// 销毁之前的实例
|
|
|
|
|
if (annoInstance) {
|
|
|
|
|
annoInstance.destroy();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 使用 Annotorious 3 的新 API 创建实例
|
|
|
|
|
annoInstance = createImageAnnotator(annotatorImage.value, {
|
|
|
|
|
locale: 'auto',
|
|
|
|
|
theme: 'dark'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 添加事件监听器
|
|
|
|
|
annoInstance.on('createAnnotation', handleAnnotationCreated);
|
|
|
|
|
annoInstance.on('updateAnnotation', handleAnnotationUpdated);
|
|
|
|
|
annoInstance.on('deleteAnnotation', handleAnnotationDeleted);
|
|
|
|
|
annoInstance.on('clickAnnotation', handleClickAnnotation);
|
|
|
|
|
annoInstance.on('selectionChanged', handleSelectionChanged);
|
|
|
|
|
|
|
|
|
|
// 加载现有标注
|
|
|
|
|
loadExistingAnnotations();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// 删除标注
|
|
|
|
|
const deleteAnnotation = async (annotation: any, index: number) => {
|
|
|
|
|
try {
|
|
|
|
|
// 从 Annotorious 中删除标注
|
|
|
|
|
if (annoInstance && annotation.annotationData) {
|
|
|
|
|
annoInstance.removeAnnotation(annotation.annotationData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 从本地标注列表中删除
|
|
|
|
|
annotations.value.splice(index, 1);
|
|
|
|
|
|
|
|
|
|
// 如果需要,可以调用后端API删除标注
|
|
|
|
|
// await MarkApi.deleteMark(annotation.id);
|
|
|
|
|
|
|
|
|
|
console.log('删除成功');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('删除标注失败:', error);
|
|
|
|
|
alert('删除失败');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// 生成HSL颜色,确保颜色差异较大
|
|
|
|
|
const generateDistinctColor = () => {
|
|
|
|
|
// 预定义一些色相值,确保颜色分布均匀
|
|
|
|
|
const hueSteps = 50;
|
|
|
|
|
const saturation = Math.floor(Math.random() * 40) + 60; // 60-100%
|
|
|
|
|
const lightness = Math.floor(Math.random() * 30) + 40; // 40-70%
|
|
|
|
|
|
|
|
|
|
// 如果已使用的颜色少于50个,使用均匀分布的色相
|
|
|
|
|
if (usedColors.length < 50) {
|
|
|
|
|
const hue = Math.floor((usedColors.length * 360) / hueSteps) % 360;
|
|
|
|
|
const color = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
|
|
|
usedColors.push(color);
|
|
|
|
|
return color;
|
|
|
|
|
} else {
|
|
|
|
|
// 超过50个后使用随机色相
|
|
|
|
|
const hue = Math.floor(Math.random() * 360);
|
|
|
|
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HSL转HEX颜色格式
|
|
|
|
|
const hslToHex = (h: number, s: number, l: number) => {
|
|
|
|
|
l /= 100;
|
|
|
|
|
const a = s * Math.min(l, 1 - l) / 100;
|
|
|
|
|
const f = n => {
|
|
|
|
|
const k = (n + h / 30) % 12;
|
|
|
|
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
|
|
|
return Math.round(255 * color).toString(16).padStart(2, '0');
|
|
|
|
|
};
|
|
|
|
|
return `#${f(0)}${f(8)}${f(4)}`;
|
|
|
|
|
}
|
|
|
|
|
// 修改 generateRandomColor 函数,排除已存在的颜色
|
|
|
|
|
const generateRandomColor = () => {
|
|
|
|
|
// 预定义一组高对比度的基础颜色
|
|
|
|
|
const baseColors = [
|
|
|
|
|
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
|
|
|
|
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
|
|
|
|
|
'#F8C471', '#82E0AA', '#F1948A', '#85C1E9', '#D7BDE2',
|
|
|
|
|
'#A3E4D7', '#FAD7A0', '#D5A6BD', '#AED6F1', '#A9DFBF',
|
|
|
|
|
'#F9E79F', '#D2B4DE', '#AED6F1', '#A2D9CE', '#FADBD8',
|
|
|
|
|
'#EBDEF0', '#D6EAF8', '#D1F2EB', '#FCF3CF', '#FDEDEC',
|
|
|
|
|
'#52BE80', '#F4D03F', '#E67E22', '#D35400', '#8E44AD',
|
|
|
|
|
'#2980B9', '#1ABC9C', '#F39C12', '#D35400', '#C0392B',
|
|
|
|
|
'#16A085', '#27AE60', '#F1C40F', '#E67E22', '#9B59B6',
|
|
|
|
|
'#3498DB', '#2ECC71', '#F1C40F', '#E74C3C', '#9B59B6'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// 获取当前已存在的颜色
|
|
|
|
|
const existingColors = annotationTypes.value.map(type => type.color);
|
|
|
|
|
|
|
|
|
|
// 过滤掉已存在的颜色
|
|
|
|
|
const availableColors = baseColors.filter(color => !existingColors.includes(color));
|
|
|
|
|
|
|
|
|
|
// 如果已使用的颜色少于50个,且还有可用颜色,优先使用预定义颜色
|
|
|
|
|
if (usedColors.length < 50 && availableColors.length > 0) {
|
|
|
|
|
// 从可用颜色中选择一个
|
|
|
|
|
const color = availableColors[usedColors.length % availableColors.length];
|
|
|
|
|
usedColors.push(color);
|
|
|
|
|
return color;
|
|
|
|
|
} else if (usedColors.length < 50 && availableColors.length === 0 && existingColors.length < baseColors.length) {
|
|
|
|
|
// 如果所有预定义颜色都被使用了,但总数还没达到预定义颜色数量,使用均匀分布色相
|
|
|
|
|
const hueSteps = 50;
|
|
|
|
|
const saturation = Math.floor(Math.random() * 40) + 60; // 60-100%
|
|
|
|
|
const lightness = Math.floor(Math.random() * 30) + 40; // 40-70%
|
|
|
|
|
const hue = Math.floor((usedColors.length * 360) / hueSteps) % 360;
|
|
|
|
|
const color = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
|
|
|
usedColors.push(color);
|
|
|
|
|
return color;
|
|
|
|
|
} else {
|
|
|
|
|
// 超过50个后或没有可用预定义颜色时生成随机颜色
|
|
|
|
|
let attempts = 0;
|
|
|
|
|
let newColor;
|
|
|
|
|
do {
|
|
|
|
|
const hue = Math.floor(Math.random() * 360);
|
|
|
|
|
const saturation = Math.floor(Math.random() * 40) + 60; // 60-100%
|
|
|
|
|
const lightness = Math.floor(Math.random() * 30) + 40; // 40-70%
|
|
|
|
|
newColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
|
|
|
attempts++;
|
|
|
|
|
// 避免无限循环,最多尝试10次
|
|
|
|
|
} while (existingColors.includes(newColor) && attempts < 10);
|
|
|
|
|
|
|
|
|
|
return newColor;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 类型定义
|
|
|
|
|
interface Project {
|
|
|
|
|
id: number
|
|
|
|
|
name: string
|
|
|
|
|
description: string
|
|
|
|
|
createTime: string
|
|
|
|
|
path: string
|
|
|
|
|
count: number
|
|
|
|
|
type: string
|
|
|
|
|
progress: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Image {
|
|
|
|
|
id: number
|
|
|
|
|
name: string
|
|
|
|
|
path: string
|
|
|
|
|
dataId: number
|
|
|
|
|
annotation: Array<{
|
|
|
|
|
class_id: number
|
|
|
|
|
center_x: number
|
|
|
|
|
center_y: number
|
|
|
|
|
width: number
|
|
|
|
|
height: number
|
|
|
|
|
polygon_points: string
|
|
|
|
|
angle: string
|
|
|
|
|
}>
|
|
|
|
|
status: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AnnotationType {
|
|
|
|
|
id: number
|
|
|
|
|
name: string
|
|
|
|
|
color: string
|
|
|
|
|
dataId: number
|
|
|
|
|
index: number
|
|
|
|
|
}
|
|
|
|
|
// 添加 Annotation 接口定义
|
|
|
|
|
interface Annotation {
|
|
|
|
|
id: number;
|
|
|
|
|
classId: number;
|
|
|
|
|
className: string;
|
|
|
|
|
markId: number;
|
|
|
|
|
centerX: number;
|
|
|
|
|
centerY: number;
|
|
|
|
|
width: number;
|
|
|
|
|
height: number;
|
|
|
|
|
polygon_points: number;
|
|
|
|
|
anagle: number;
|
|
|
|
|
dataId: number;
|
|
|
|
|
annotationData: any; // 根据实际需要定义具体类型
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 响应式数据
|
|
|
|
|
const selectedProjectId = ref<number | undefined>(undefined)
|
|
|
|
|
const currentProject = ref<Project | null>(null)
|
|
|
|
|
const currentImage = ref<Image | null>(null)
|
|
|
|
|
const projectList = ref<Project[]>([])
|
|
|
|
|
const imageList = ref<Image[]>([])
|
|
|
|
|
const annotationTypes = ref<AnnotationType[]>([])
|
|
|
|
|
const annotations = ref<Annotation[]>([])
|
|
|
|
|
|
|
|
|
|
// 添加类型对话框相关
|
|
|
|
|
const addTypeDialogVisible = ref(false)
|
|
|
|
|
const newTypeForm = reactive({
|
|
|
|
|
name: '',
|
|
|
|
|
color: '#409EFF',
|
|
|
|
|
dataId: 0
|
|
|
|
|
})
|
|
|
|
|
const typeFormRef = ref()
|
|
|
|
|
|
|
|
|
|
// 折叠面板控制
|
|
|
|
|
const activeLeftPanels = ref(['project'])
|
|
|
|
|
const activeRightPanels = ref(['images'])
|
|
|
|
|
|
|
|
|
|
// DOM引用
|
|
|
|
|
const annotatorContainer = ref<HTMLElement | null>(null)
|
|
|
|
|
const annotatorImage = ref<HTMLImageElement | null>(null)
|
|
|
|
|
// 在 script setup 中添加时间格式化函数
|
|
|
|
|
// 修改时间格式化函数
|
|
|
|
|
const formatTimestamp = (timestamp: string) => {
|
|
|
|
|
if (!timestamp) return ''
|
|
|
|
|
const date = new Date(timestamp)
|
|
|
|
|
const year = date.getFullYear()
|
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
|
|
|
const day = String(date.getDate()).padStart(2, '0')
|
|
|
|
|
const hours = String(date.getHours()).padStart(2, '0')
|
|
|
|
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
|
|
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
|
|
|
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
|
|
|
}
|
|
|
|
|
// 获取项目列表
|
|
|
|
|
const getProjectList = async () => {
|
|
|
|
|
try {
|
|
|
|
|
// 调用实际API获取项目列表
|
|
|
|
|
const response = await MarkApi.getProjectList()
|
|
|
|
|
projectList.value = response
|
|
|
|
|
console.log(projectList.value)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('获取项目列表失败:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 项目选择变更处理
|
|
|
|
|
const handleProjectChange = async (projectId: number) => {
|
|
|
|
|
// 查找选中的项目
|
|
|
|
|
currentProject.value = projectList.value.find(p => p.id === projectId) || null
|
|
|
|
|
|
|
|
|
|
if (currentProject.value) {
|
|
|
|
|
// 获取该项目下的图片列表和标注类型
|
|
|
|
|
await Promise.all([
|
|
|
|
|
getImageList(projectId),
|
|
|
|
|
getAnnotationTypes(projectId)
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 更新图片状态
|
|
|
|
|
const updateImageStatus = async (imageId: number, status: number) => {
|
|
|
|
|
try {
|
|
|
|
|
// 假设有一个更新图片状态的API
|
|
|
|
|
await MarkApi.updateImageStatus({ id: imageId, status: status })
|
|
|
|
|
// 更新本地数据
|
|
|
|
|
const image = imageList.value.find(img => img.id === imageId)
|
|
|
|
|
if (image) {
|
|
|
|
|
image.status = status
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('更新图片状态失败:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 获取图片列表
|
|
|
|
|
const getImageList = async (projectId: number) => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await MarkApi.getImageList({ dataId: projectId })
|
|
|
|
|
|
|
|
|
|
imageList.value = response
|
|
|
|
|
console.log(imageList.value);
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('获取图片列表失败:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 获取标注类型列表
|
|
|
|
|
const getAnnotationTypes = async (projectId: number) => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await MarkApi.getTypeList({ dataId: projectId })
|
|
|
|
|
annotationTypes.value = response
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('获取标注类型失败:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 修改 selectImage 方法中的数据处理
|
|
|
|
|
const selectImage = async (image: Image) => {
|
|
|
|
|
currentImage.value = image;
|
|
|
|
|
updateImageStatus(image.id, 1);
|
|
|
|
|
// console.log(annotations.value);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理标注信息
|
|
|
|
|
if (annotations.value && annotations.value.length > 0) {
|
|
|
|
|
// annotations.value = image.annotation.map((anno: any) => ({
|
|
|
|
|
// id: anno.id || Date.now(),
|
|
|
|
|
// classId: anno.class_id,
|
|
|
|
|
// className: annotationTypes.value.find(type => type.id === anno.class_id)?.name || '未知类型',
|
|
|
|
|
// markId: anno.mark_id || 0,
|
|
|
|
|
// centerX: anno.center_x,
|
|
|
|
|
// centerY: anno.center_y,
|
|
|
|
|
// width: anno.width,
|
|
|
|
|
// height: anno.height,
|
|
|
|
|
// polygon_points: anno.polygon_points,
|
|
|
|
|
// anagle: anno.angle,
|
|
|
|
|
// dataId: anno.dataId || 0,
|
|
|
|
|
// annotationData: anno.annotationData || null
|
|
|
|
|
// }));
|
|
|
|
|
MarkApi.createMarkInfo(annotations.value)
|
|
|
|
|
annotations.value = [];
|
|
|
|
|
} else {
|
|
|
|
|
annotations.value = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 初始化 annotorious
|
|
|
|
|
initAnnotorious();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 转换标注格式为 W3C 标准
|
|
|
|
|
const convertToW3CFormat = (annotation: any) => {
|
|
|
|
|
return {
|
|
|
|
|
"@context": "http://www.w3.org/ns/anno.jsonld",
|
|
|
|
|
"id": `#annotation-${annotation.class_id}`,
|
|
|
|
|
"type": "Annotation",
|
|
|
|
|
"body": [{
|
|
|
|
|
"type": "TextualBody",
|
|
|
|
|
"value": annotationTypes.value.find(t => t.id === annotation.class_id)?.name || '未知类型'
|
|
|
|
|
}],
|
|
|
|
|
"target": {
|
|
|
|
|
"source": currentImage.value?.path,
|
|
|
|
|
"selector": {
|
|
|
|
|
"type": "FragmentSelector",
|
|
|
|
|
"conformsTo": "http://www.w3.org/TR/media-frags/",
|
|
|
|
|
"value": `xywh=pixel:${annotation.center_x - annotation.width/2},${annotation.center_y - annotation.height/2},${annotation.width},${annotation.height}`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 修改 handleAnnotationCreated 函数
|
|
|
|
|
const handleAnnotationCreated = (annotation: any) => {
|
|
|
|
|
console.log('创建标注:', annotation);
|
|
|
|
|
// 解析标注位置信息
|
|
|
|
|
const positionInfo = parseAnnotationPosition(annotation);
|
|
|
|
|
console.log(positionInfo);
|
|
|
|
|
|
|
|
|
|
if (positionInfo) {
|
|
|
|
|
// 如果有默认类型,使用默认类型
|
|
|
|
|
let classId = null;
|
|
|
|
|
let className = '未分类';
|
|
|
|
|
let typeColor = null;
|
|
|
|
|
|
|
|
|
|
if (defaultTypeId.value) {
|
|
|
|
|
console.log(defaultTypeId.value);
|
|
|
|
|
|
|
|
|
|
const type = annotationTypes.value.find(t => t.id === defaultTypeId.value);
|
|
|
|
|
if (type) {
|
|
|
|
|
classId = defaultTypeId.value;
|
|
|
|
|
className = type.name;
|
|
|
|
|
typeColor = type.color;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 添加到标注结果列表
|
|
|
|
|
const newAnnotation: Annotation = {
|
|
|
|
|
id: Date.now(),
|
|
|
|
|
classId: classId,
|
|
|
|
|
className: className,
|
|
|
|
|
markId: currentImage.value?.id,
|
|
|
|
|
centerX: positionInfo.center_x,
|
|
|
|
|
centerY: positionInfo.center_y,
|
|
|
|
|
width: positionInfo.width,
|
|
|
|
|
height: positionInfo.height,
|
|
|
|
|
polygon_points: 0,
|
|
|
|
|
anagle: 0,
|
|
|
|
|
dataId: currentImage.value?.id || 0,
|
|
|
|
|
annotationData: annotation
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
annotations.value.push(newAnnotation);
|
|
|
|
|
console.log(newAnnotation);
|
|
|
|
|
|
|
|
|
|
// 如果有默认类型,更新标注颜色
|
|
|
|
|
if (classId && typeColor && annoInstance) {
|
|
|
|
|
annotation.bodies[0] = typeColor;
|
|
|
|
|
annoInstance.setStyle((annotation, state) => {
|
|
|
|
|
if(annotation.bodies[0]){
|
|
|
|
|
const color = annotation.bodies[0];
|
|
|
|
|
console.log(color);
|
|
|
|
|
return {
|
|
|
|
|
fill: color,
|
|
|
|
|
fillOpacity: 0.25,
|
|
|
|
|
stroke: color,
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
};
|
|
|
|
|
} else return null;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理标注更新事件
|
|
|
|
|
const handleAnnotationUpdated = (annotation: any, previous: any) => {
|
|
|
|
|
console.log('更新标注:', annotation);
|
|
|
|
|
// 解析标注位置信息
|
|
|
|
|
const positionInfo = parseAnnotationPosition(annotation);
|
|
|
|
|
if (positionInfo) {
|
|
|
|
|
// 更新标注结果列表中的对应项
|
|
|
|
|
const index = annotations.value.findIndex((item: any) =>
|
|
|
|
|
item.annotationData && item.annotationData.id === annotation.id);
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
annotations.value[index] = {
|
|
|
|
|
...annotations.value[index],
|
|
|
|
|
center_x: positionInfo.center_x,
|
|
|
|
|
center_y: positionInfo.center_y,
|
|
|
|
|
width: positionInfo.width,
|
|
|
|
|
height: positionInfo.height,
|
|
|
|
|
annotationData: annotation
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 处理标注删除事件
|
|
|
|
|
const handleAnnotationDeleted = (annotation: any) => {
|
|
|
|
|
console.log('删除标注:', annotation);
|
|
|
|
|
// 从标注结果列表中移除
|
|
|
|
|
const index = annotations.value.findIndex((item: any) =>
|
|
|
|
|
item.annotationData && item.annotationData.id === annotation.id);
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
annotations.value.splice(index, 1);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 处理点击标注事件
|
|
|
|
|
const handleClickAnnotation = (annotation: any, shape: any, sel: any) => {
|
|
|
|
|
console.log('点击标注:', annotation);
|
|
|
|
|
// 可以在这里实现标注选中后的操作,如高亮显示等
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 处理选择变化事件
|
|
|
|
|
const handleSelectionChanged = (selected: any[]) => {
|
|
|
|
|
console.log('选择变化:', selected);
|
|
|
|
|
// 可以在这里处理多选等操作
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 修复 parseAnnotationPosition 函数,确保能正确解析坐标
|
|
|
|
|
const parseAnnotationPosition = (annotation: any) => {
|
|
|
|
|
try {
|
|
|
|
|
// 处理 Annotorious 3 格式 - geometry 方式
|
|
|
|
|
|
|
|
|
|
if (annotation.target?.selector?.geometry) {
|
|
|
|
|
const geometry = annotation.target.selector.geometry;
|
|
|
|
|
console.log('Rectangle geometry:', annotation.target.selector.type);
|
|
|
|
|
if (annotation.target.selector.type === 'RECTANGLE') {
|
|
|
|
|
// Rectangle 格式: { type: 'Rectangle', coordinates: [x, y, width, height] }
|
|
|
|
|
|
|
|
|
|
// const [x, y, width, height] = geometry.coordinates;
|
|
|
|
|
return {
|
|
|
|
|
center_x: geometry.x,
|
|
|
|
|
center_y: geometry.y,
|
|
|
|
|
width: geometry.w,
|
|
|
|
|
height: geometry.h
|
|
|
|
|
};
|
|
|
|
|
} else if (geometry.type === 'Polygon') {
|
|
|
|
|
// Polygon 格式处理
|
|
|
|
|
const coordinates = geometry.coordinates[0]; // 外环坐标
|
|
|
|
|
if (coordinates.length >= 4) {
|
|
|
|
|
// 计算边界框
|
|
|
|
|
let minX = Math.min(...coordinates.map((p: number[]) => p[0]));
|
|
|
|
|
let maxX = Math.max(...coordinates.map((p: number[]) => p[0]));
|
|
|
|
|
let minY = Math.min(...coordinates.map((p: number[]) => p[1]));
|
|
|
|
|
let maxY = Math.max(...coordinates.map((p: number[]) => p[1]));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
center_x: (minX + maxX) / 2,
|
|
|
|
|
center_y: (minY + maxY) / 2,
|
|
|
|
|
width: maxX - minX,
|
|
|
|
|
height: maxY - minY
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 处理 FragmentSelector 格式
|
|
|
|
|
if (annotation.target?.selector?.type === 'FragmentSelector' && annotation.target?.selector?.value) {
|
|
|
|
|
const selector = annotation.target.selector;
|
|
|
|
|
// 解析 xywh 格式坐标 "xywh=pixel:x,y,width,height"
|
|
|
|
|
const coords = selector.value.replace('xywh=pixel:', '').split(',');
|
|
|
|
|
if (coords.length === 4) {
|
|
|
|
|
const [x, y, width, height] = coords.map(Number);
|
|
|
|
|
return {
|
|
|
|
|
center_x: x + width / 2,
|
|
|
|
|
center_y: y + height / 2,
|
|
|
|
|
width: width,
|
|
|
|
|
height: height
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 处理其他可能的格式
|
|
|
|
|
if (annotation.target?.selector?.conformsTo) {
|
|
|
|
|
const selector = annotation.target.selector;
|
|
|
|
|
if (selector.value) {
|
|
|
|
|
const coords = selector.value.replace('xywh=pixel:', '').split(',');
|
|
|
|
|
if (coords.length === 4) {
|
|
|
|
|
const [x, y, width, height] = coords.map(Number);
|
|
|
|
|
return {
|
|
|
|
|
center_x: x + width / 2,
|
|
|
|
|
center_y: y + height / 2,
|
|
|
|
|
width: width,
|
|
|
|
|
height: height
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.warn('无法解析标注位置信息:', annotation);
|
|
|
|
|
return null;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('解析标注位置信息失败:', error);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// 修改 setAnnotationType 函数,增强颜色更新功能
|
|
|
|
|
const setAnnotationType = (annotationIndex: number, typeId: number) => {
|
|
|
|
|
const type = annotationTypes.value.find(t => t.id === typeId);
|
|
|
|
|
if (type && annoInstance) {
|
|
|
|
|
// console.log(type);
|
|
|
|
|
|
|
|
|
|
// 更新标注结果列表中的类型信息
|
|
|
|
|
annotations.value[annotationIndex].className = type.name;
|
|
|
|
|
annotations.value[annotationIndex].color = type.color;
|
|
|
|
|
annotations.value[annotationIndex].typeId = typeId;
|
|
|
|
|
// console.log(annotations);
|
|
|
|
|
|
|
|
|
|
// 保存为默认类型
|
|
|
|
|
defaultTypeId.value = typeId;
|
|
|
|
|
|
|
|
|
|
// 更新 Annotorious 中对应标注的样式
|
|
|
|
|
const annotationData = annotations.value[annotationIndex].annotationData;
|
|
|
|
|
annotationData.bodies[0] = type.color;
|
|
|
|
|
// 设置新的样式,修改填充颜色和边框颜色
|
|
|
|
|
console.log(annotationData);
|
|
|
|
|
|
|
|
|
|
// 同时,更新 DOM 样式
|
|
|
|
|
if (annotationData) {
|
|
|
|
|
annoInstance.setStyle((annotationData, state) => {
|
|
|
|
|
if(annotationData.bodies[0]){
|
|
|
|
|
|
|
|
|
|
const color = annotationData.bodies[0];
|
|
|
|
|
console.log(color);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
fill: color,
|
|
|
|
|
fillOpacity:0.25 ,
|
|
|
|
|
stroke: color,
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
};
|
|
|
|
|
}else return null;
|
|
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(annotations.value[annotationIndex]);
|
|
|
|
|
// annoInstance.update(annotationData);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 添加选择标注的函数
|
|
|
|
|
const selectAnnotation = (annotation: any) => {
|
|
|
|
|
if (annoInstance && annotation.annotationData) {
|
|
|
|
|
// 选中对应的标注
|
|
|
|
|
// 触发标注点击事件
|
|
|
|
|
// const event = new CustomEvent('annotationClicked', { detail: { annotation } });
|
|
|
|
|
// annoInstance.getElement().dispatchEvent(event);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// 加载已存在的标注(修改此函数以正确处理类型和颜色)
|
|
|
|
|
const loadExistingAnnotations = () => {
|
|
|
|
|
console.log(currentImage.value);
|
|
|
|
|
if (annoInstance && currentImage.value && currentImage.value.id) {
|
|
|
|
|
|
|
|
|
|
MarkApi.getMarkInfoList(currentImage.value.id).then((res) => {
|
|
|
|
|
if (res) {
|
|
|
|
|
console.log(res);
|
|
|
|
|
|
|
|
|
|
res.forEach((item) => {
|
|
|
|
|
console.log(item);
|
|
|
|
|
|
|
|
|
|
annoInstance.addAnnotation(item.annotationData)
|
|
|
|
|
annotations.value.push(item)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// 显示添加类型对话框
|
|
|
|
|
const showAddTypeDialog = () => {
|
|
|
|
|
if (!selectedProjectId.value) {
|
|
|
|
|
alert('请先选择项目')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
addTypeDialogVisible.value = true
|
|
|
|
|
// 重置表单
|
|
|
|
|
newTypeForm.name = ''
|
|
|
|
|
newTypeForm.color = generateRandomColor()
|
|
|
|
|
newTypeForm.dataId = selectedProjectId.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 添加标注类型
|
|
|
|
|
const addAnnotationType = async () => {
|
|
|
|
|
if (!typeFormRef.value) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await typeFormRef.value.validate()
|
|
|
|
|
|
|
|
|
|
const response = await MarkApi.createType({
|
|
|
|
|
name: newTypeForm.name,
|
|
|
|
|
color: newTypeForm.color,
|
|
|
|
|
dataId: newTypeForm.dataId
|
|
|
|
|
})
|
|
|
|
|
console.log(response);
|
|
|
|
|
|
|
|
|
|
addTypeDialogVisible.value = false
|
|
|
|
|
// 重新获取标注类型列表
|
|
|
|
|
if (selectedProjectId.value) {
|
|
|
|
|
await getAnnotationTypes(selectedProjectId.value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('添加标注类型失败:', error)
|
|
|
|
|
alert('添加失败')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 删除标注类型
|
|
|
|
|
const deleteAnnotationType = async (typeId: number) => {
|
|
|
|
|
try {
|
|
|
|
|
if (confirm('确定要删除这个标注类型吗?')) {
|
|
|
|
|
await MarkApi.deleteType(typeId)
|
|
|
|
|
|
|
|
|
|
// 重新获取标注类型列表
|
|
|
|
|
if (selectedProjectId.value) {
|
|
|
|
|
await getAnnotationTypes(selectedProjectId.value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('删除标注类型失败:', error)
|
|
|
|
|
alert('删除失败')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 编辑标注
|
|
|
|
|
const editAnnotation = (annotation: any) => {
|
|
|
|
|
console.log('编辑标注:', annotation)
|
|
|
|
|
// 实现编辑逻辑
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 初始化
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
getProjectList()
|
|
|
|
|
})
|
|
|
|
|
// 清理资源
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
if (annoInstance) {
|
|
|
|
|
annoInstance.destroy()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
.mark-container {
|
|
|
|
|
display: flex;
|
|
|
|
|
height: 82vh;
|
|
|
|
|
padding: 10px;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.left-container,
|
|
|
|
|
.right-container {
|
|
|
|
|
background-color: var(--el-bg-color-overlay);
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
:deep(.el-collapse-item__header) {
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
padding-left: 15px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-collapse-item__content) {
|
|
|
|
|
padding: 15px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.center-container {
|
|
|
|
|
flex: 1;
|
|
|
|
|
padding: 0 10px;
|
|
|
|
|
background-color: #f5f5f5;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
|
|
.annotator-wrapper {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
|
|
.annotator-image {
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
max-height: 100%;
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
|
|
|
|
.image-item {
|
|
|
|
|
border: 1px solid #ebeef5;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
padding: 5px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.3s;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
border-color: #409eff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.active {
|
|
|
|
|
border-color: #409eff;
|
|
|
|
|
background-color: #ecf5ff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-wrapper {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 80px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
|
|
|
|
.thumbnail {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
}
|
|
|
|
|
.status-indicator {
|
|
|
|
|
position: absolute;
|
|
|
|
|
bottom: 5px;
|
|
|
|
|
right: 5px;
|
|
|
|
|
width: 20px;
|
|
|
|
|
height: 20px;
|
|
|
|
|
background-color: #67C23A;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
color: white;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-name {
|
|
|
|
|
text-align: center;
|
|
|
|
|
margin-top: 5px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.color-circle {
|
|
|
|
|
width: 20px;
|
|
|
|
|
height: 20px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
border: 1px solid #eee;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.coordinates-info {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
|
|
|
|
div {
|
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.type-header {
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
text-align: right;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.left-scrollbar {
|
|
|
|
|
height: calc(90vh - 430px);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-scrollbar {
|
|
|
|
|
height: calc(100vh - 300px);
|
|
|
|
|
}
|
|
|
|
|
</style>
|