add line | add circle | zoom | selectable geometry | geometry define entities type

This commit is contained in:
taDuc
2026-04-11 10:48:59 +07:00
parent 4969c8cc57
commit 3023fa947c
15 changed files with 1435 additions and 212 deletions

View File

@@ -0,0 +1,546 @@
"use client";
import { type CSSProperties } from "react";
import { Entity } from "@/api/entities";
import { Feature } from "@/lib/useEditorState";
import {
EntityTypeGroupId,
EntityTypeOption,
findEntityTypeOption,
groupEntityTypeOptions,
} from "@/lib/entityTypeOptions";
type EntityFormState = {
name: string;
slug: string;
type_id: string;
};
type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
type Props = {
selectedFeature: Feature | null;
selectedFeatureEntitySummary: string;
selectedFeatureBindingSummary: string;
entities: Entity[];
selectedGeometryEntityIds: string[];
onEntityIdsChange: (values: string[]) => void;
entitySearchQuery: string;
onEntitySearchQueryChange: (value: string) => void;
entitySearchResults: Entity[];
selectedSearchEntityId: string | null;
onSelectSearchEntityId: (value: string | null) => void;
onAddSelectedSearchEntity: () => void;
isEntitySearchLoading: boolean;
entityForm: EntityFormState;
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
entityTypeOptions: EntityTypeOption[];
geometryMetaForm: GeometryMetaFormState;
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
isEntitySubmitting: boolean;
onCreateEntityAndAttach: () => void;
onApplyEntitiesForSelectedGeometry: () => void;
changeCount: number;
entityFormStatus: string | null;
};
export default function SelectedGeometryPanel({
selectedFeature,
selectedFeatureEntitySummary,
selectedFeatureBindingSummary,
entities,
selectedGeometryEntityIds,
onEntityIdsChange,
entitySearchQuery,
onEntitySearchQueryChange,
entitySearchResults,
selectedSearchEntityId,
onSelectSearchEntityId,
onAddSelectedSearchEntity,
isEntitySearchLoading,
entityForm,
onEntityFormChange,
entityTypeOptions,
geometryMetaForm,
onGeometryMetaFormChange,
isEntitySubmitting,
onCreateEntityAndAttach,
onApplyEntitiesForSelectedGeometry,
changeCount,
entityFormStatus,
}: Props) {
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const allowedGroupIds = selectedFeature
? getAllowedGroupIdsForGeometry(selectedFeature.geometry.type)
: [];
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id)
);
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
const hasCurrentTypeOption = entityTypeOptions.some((option) => option.value === entityForm.type_id);
const geometryBucket = selectedFeature ? toGeometryBucket(selectedFeature.geometry.type) : null;
const selectedTypeBucket = selectedTypeOption ? toTypeBucket(selectedTypeOption) : null;
const isGeometryCompatible =
geometryBucket && selectedTypeBucket
? geometryBucket === selectedTypeBucket
: true;
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
Selected Geometry
</div>
{!selectedFeature ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
Vào mode Select chọn 1 geometry đ điền entity.
</div>
) : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}>
ID: {String(selectedFeature.properties.id)}
</div>
<div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {selectedFeatureEntitySummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn:
</div>
{selectedGeometryEntityIds.length ? (
<div style={{ display: "grid", gap: "6px" }}>
{selectedGeometryEntityIds.map((entityId) => {
const entity = entities.find((item) => item.id === entityId) || null;
const label = entity?.name
? `${entity.name} (${entityId})`
: entityId;
return (
<div
key={entityId}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
background: "#111827",
border: "1px solid #334155",
borderRadius: "6px",
padding: "6px 8px",
}}
>
<span style={{ color: "#e2e8f0" }}>{label}</span>
<button
type="button"
onClick={() =>
onEntityIdsChange(
selectedGeometryEntityIds.filter((id) => id !== entityId)
)
}
disabled={isEntitySubmitting}
style={removeButtonStyle}
>
Bỏ
</button>
</div>
);
})}
</div>
) : (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Chưa entity nào đưc gắn.
</div>
)}
<input
value={entitySearchQuery}
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
placeholder="Search entity theo name..."
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<select
value={selectedSearchEntityId || ""}
onChange={(event) =>
onSelectSearchEntityId(event.target.value ? event.target.value : null)
}
disabled={isEntitySubmitting || isEntitySearchLoading}
style={entityInputStyle}
>
<option value="">-- Chọn entity từ kết quả search --</option>
{entitySearchResults.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name} ({entity.id})
</option>
))}
</select>
<button
type="button"
onClick={onAddSelectedSearchEntity}
disabled={isEntitySubmitting || isEntitySearchLoading}
style={secondaryActionButtonStyle}
>
Chọn đ thêm
</button>
{isEntitySearchLoading ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
Đang tìm entity...
</div>
) : null}
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Geometry phải ít nhất 1 entity đ Save.
</div>
<input
value={entityForm.name}
onChange={(event) => onEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.slug}
onChange={(event) => onEntityFormChange("slug", event.target.value)}
placeholder="Slug"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #243244",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Entity type presets
</div>
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
Chọn nhanh theo nhóm hình học bạn cần vẽ.
</div>
{visibleGroupedEntityTypeOptions.map((group) => {
const color = GROUP_COLORS[group.id];
return (
<div
key={group.id}
style={{
border: `1px solid ${color.border}`,
borderRadius: "8px",
padding: "8px",
background: color.background,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: "8px",
alignItems: "center",
marginBottom: "6px",
}}
>
<span style={{ color: color.text, fontWeight: 600, fontSize: "12px" }}>
{group.label}
</span>
<span style={{ color: color.mutedText, fontSize: "11px" }}>
{group.geometryLabel}
</span>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
{group.options.map((option) => {
const active = option.value === entityForm.type_id;
return (
<button
key={option.value}
type="button"
onClick={() => onEntityFormChange("type_id", option.value)}
disabled={isEntitySubmitting}
style={{
border: active
? `1px solid ${color.activeBorder}`
: `1px solid ${color.chipBorder}`,
borderRadius: "999px",
padding: "4px 9px",
background: active ? color.activeBackground : color.chipBackground,
color: active ? color.activeText : color.text,
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
fontSize: "11px",
fontWeight: active ? 600 : 500,
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
{option.label}
</button>
);
})}
</div>
</div>
);
})}
</div>
<select
value={entityForm.type_id}
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}>
Custom Type ({entityForm.type_id})
</option>
) : null}
{visibleGroupedEntityTypeOptions.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : entityForm.type_id ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{entityForm.type_id}</b>
</div>
) : null}
{!isGeometryCompatible && selectedTypeOption ? (
<div style={{ color: "#fbbf24", fontSize: "12px" }}>
Type <b>{selectedTypeOption.label}</b> hợp với {selectedTypeBucket}, nhưng geometry hiện tại {geometryBucket}.
</div>
) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.binding}
onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}
placeholder="binding ids (vd: geo-id-1, geo-id-2)"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
onClick={onCreateEntityAndAttach}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
Tạo Entity + Gắn
</button>
<button
onClick={onApplyEntitiesForSelectedGeometry}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
Áp dụng Entities + Metadata
</button>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Geometry mới sẽ lưu entity khi bấm Save.
</div>
) : null}
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
{entityFormStatus}
</div>
) : null}
</div>
)}
</div>
);
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
};
const removeButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "4px 8px",
cursor: "pointer",
background: "#7f1d1d",
color: "#ffffff",
fontSize: "12px",
};
const secondaryActionButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: "pointer",
background: "#1d4ed8",
color: "#ffffff",
};
const GROUP_COLORS: Record<
EntityTypeGroupId,
{
background: string;
border: string;
text: string;
mutedText: string;
chipBackground: string;
chipBorder: string;
activeBackground: string;
activeBorder: string;
activeText: string;
}
> = {
split: {
background: "#3f1d24",
border: "#7f1d1d",
text: "#fecaca",
mutedText: "#fda4af",
chipBackground: "#4c1d27",
chipBorder: "#7f1d1d",
activeBackground: "#7f1d1d",
activeBorder: "#fecaca",
activeText: "#ffffff",
},
route: {
background: "#082f49",
border: "#0c4a6e",
text: "#bae6fd",
mutedText: "#7dd3fc",
chipBackground: "#0c4a6e",
chipBorder: "#155e75",
activeBackground: "#0369a1",
activeBorder: "#bae6fd",
activeText: "#f0f9ff",
},
area_polygon: {
background: "#1f3a2a",
border: "#166534",
text: "#bbf7d0",
mutedText: "#86efac",
chipBackground: "#14532d",
chipBorder: "#166534",
activeBackground: "#15803d",
activeBorder: "#bbf7d0",
activeText: "#f0fdf4",
},
area_circle: {
background: "#3f2b05",
border: "#92400e",
text: "#fde68a",
mutedText: "#fcd34d",
chipBackground: "#78350f",
chipBorder: "#92400e",
activeBackground: "#b45309",
activeBorder: "#fde68a",
activeText: "#fffbeb",
},
point: {
background: "#2e1065",
border: "#6d28d9",
text: "#ddd6fe",
mutedText: "#c4b5fd",
chipBackground: "#4c1d95",
chipBorder: "#6d28d9",
activeBackground: "#7c3aed",
activeBorder: "#ddd6fe",
activeText: "#f5f3ff",
},
};
type GeometryBucket = "point" | "line" | "polygon";
function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBucket {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point";
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "line";
}
return "polygon";
}
function toTypeBucket(option: EntityTypeOption): GeometryBucket {
if (option.geometryPreset === "point") {
return "point";
}
if (option.geometryPreset === "line") {
return "line";
}
return "polygon";
}
function getAllowedGroupIdsForGeometry(
geometryType: Feature["geometry"]["type"]
): EntityTypeGroupId[] {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return ["point"];
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return ["split", "route"];
}
return ["area_polygon", "area_circle"];
}