547 lines
22 KiB
TypeScript
547 lines
22 KiB
TypeScript
"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 và 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 có 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 có í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 là {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"];
|
|
}
|