Skip to content
11 changes: 11 additions & 0 deletions Setting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ public IEnumerable<KeyValuePair<string, string>> ReadEntries(IList<IDictionaryEn
["RouteFilter.UI.Node"] = Chinese ? "节点" : "Node",
["RouteFilter.UI.Segment"] = Chinese ? "整段路段" : "Segment",
["RouteFilter.UI.Search"] = Chinese ? "搜索车辆资产" : "Search vehicle assets",
["RouteFilter.UI.Show"] = Chinese ? "显示" : "Show",
["RouteFilter.UI.ShowAll"] = Chinese ? "全部" : "All",
["RouteFilter.UI.ShowForbidden"] = Chinese ? "禁行" : "Forbidden",
["RouteFilter.UI.ShowAllowed"] = Chinese ? "放行" : "Allowed",
["RouteFilter.UI.SortBy"] = Chinese ? "排序" : "Sort by",
["RouteFilter.UI.SortName"] = Chinese ? "名称" : "Name",
["RouteFilter.UI.SortAscending"] = Chinese ? "升序" : "Ascending",
["RouteFilter.UI.SortDescending"] = Chinese ? "降序" : "Descending",
["RouteFilter.UI.Empty"] = Chinese ? "没有匹配的车辆资产" : "No matching vehicle assets",
["RouteFilter.UI.ForbiddenTitle"] = Chinese ? "禁行资产" : "Forbidden assets",
["RouteFilter.UI.ForbiddenHint"] = Chinese ? "选中的资产将被禁止通行。" : "Selected assets will be blocked.",
Expand All @@ -90,6 +98,9 @@ public IEnumerable<KeyValuePair<string, string>> ReadEntries(IList<IDictionaryEn
["RouteFilter.UI.HoverInfo"] = Chinese ? "将鼠标移到资产上查看基础参数。" : "Hover an asset to view its base parameters.",
["RouteFilter.UI.ForbidAll"] = Chinese ? "全部资产禁行" : "Forbid all assets",
["RouteFilter.UI.AllowAll"] = Chinese ? "全部资产放行" : "Allow all assets",
["RouteFilter.UI.ForbidAsset"] = Chinese ? "禁止此资产通行" : "Forbid asset",
["RouteFilter.UI.AllowAsset"] = Chinese ? "允许此资产通行" : "Allow asset",
["RouteFilter.UI.RefreshAssets"] = Chinese ? "刷新资产列表" : "Refresh asset list",
["RouteFilter.UI.NodeSelected"] = Chinese ? "已选中节点" : "Node selected",
["RouteFilter.UI.SegmentSelected"] = Chinese ? "已选中路段" : "Segment selected",
["RouteFilter.UI.SelectTarget"] = Chinese ? "请先在地图上单击节点或路段" : "Click a node or segment on the map first",
Expand Down
10 changes: 9 additions & 1 deletion UI/src/routeFilterUI.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
.warning { flex: 0 0 auto; margin-top: 8rem; padding: 9rem 11rem; border-left: 3rem solid #ff5e42; background: rgba(255,94,66,.12); }
.warning strong, .warning span { display: block; }
.warning span { margin-top: 3rem; opacity: .82; font-size: 12rem; }
.actions button, .modes button, .expand, .pager button, .applyActions button {
.actions button, .modes button, .expand, .pager button, .applyActions button, .catalogControls button {
display: flex; align-items: center; justify-content: center;
border: 0; border-radius: 6rem; color: white; background: rgba(255,255,255,.09); cursor: pointer;
}
Expand All @@ -34,6 +34,12 @@
.assetHeader { display: flex; flex: 0 0 auto; justify-content: space-between; align-items: center; margin-top: 10rem; }
.assetHeader small { opacity: .72; }
.search { width: 100%; margin-top: 7rem; padding: 8rem 10rem; border: 1rem solid rgba(255,255,255,.18); border-radius: 6rem; color: white; background: rgba(0,0,0,.22); }
.catalogControls { display: flex; flex: 0 0 auto; align-items: center; margin-top: 7rem; }
.catalogControls label { display: flex; flex: 1 1 auto; min-width: 0; align-items: center; font-size: 11rem; }
.catalogControls label > span { flex: 0 0 auto; margin-right: 6rem; opacity: .75; }
.catalogControls select { flex: 1 1 auto; min-width: 0; height: 28rem; padding: 0 7rem; border: 1rem solid rgba(255,255,255,.18); border-radius: 6rem; color: white; background: #26333b; }
.catalogControls label + label { margin-left: 7rem; }
.catalogControls .sortDirection { flex: 0 0 30rem; width: 30rem; height: 28rem; margin-left: 7rem; font-size: 16rem; }
.details { flex: 0 0 58rem; margin-top: 7rem; padding: 7rem 9rem; border-radius: 6rem; background: rgba(0,0,0,.2); font-size: 12rem; overflow: hidden; }
.details div { display: flex; align-items: center; margin-bottom: 5rem; }
.details span { display: inline-block; margin-right: 12rem; opacity: .82; }
Expand All @@ -47,8 +53,10 @@
.assetRow.child { margin-left: 24rem; width: auto; }
.assetRow.forbidden { background-color: rgba(190, 58, 40, .78); }
.assetRow.partial { background-color: rgba(202, 125, 35, .62); }
.assetRow.contextOnly { opacity: .72; background-color: rgba(255,255,255,.035); }
.expand, .expandSpacer { flex: 0 0 25rem; width: 25rem; margin-right: 5rem; }
.expand { font-size: 20rem; line-height: 30rem; }
.checkSpacer { flex: 0 0 20rem; width: 20rem; height: 20rem; }
.check { flex: 0 0 20rem; display: flex; align-items: center; justify-content: center; width: 20rem; height: 20rem; padding: 0; border: 1rem solid #a9b3b9; border-radius: 10rem; color: white; background-color: rgba(0,0,0,.2); }
.checked { border-color: white; background-color: #a93428; }
.partialCheck { background-color: #a96a23; }
Expand Down
100 changes: 81 additions & 19 deletions UI/src/routeFilterUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ type VehicleAsset = {
braking: number; parentId: number; trailer: boolean;
};

type AssetVisibility = "all" | "forbidden" | "allowed";
type AssetSort = "name" | "maxSpeed" | "acceleration" | "braking";
type SortDirection = "ascending" | "descending";

const toolActive$ = bindValue<boolean>(mod.id, "toolActive", false);
const targetMode$ = bindValue<number>(mod.id, "targetMode", 0);
const targetTransport$ = bindValue<number>(mod.id, "targetTransport", 0);
Expand All @@ -31,6 +35,25 @@ const parseCatalog = (raw: string): VehicleAsset[] => raw.split("\n").reduce<Veh
return result;
}, []);

const matchesCatalogFilters = (asset: VehicleAsset, search: string, visibility: AssetVisibility, selected: Set<number>) => {
if (search && !asset.name.toLocaleLowerCase().includes(search)) return false;
if (visibility === "forbidden") return selected.has(asset.id);
if (visibility === "allowed") return !selected.has(asset.id);
return true;
};

const compareNames = (left: VehicleAsset, right: VehicleAsset) =>
left.name.toLocaleLowerCase().localeCompare(right.name.toLocaleLowerCase()) || left.id - right.id;

const compareAssets = (left: VehicleAsset, right: VehicleAsset, sort: AssetSort) => {
if (sort === "name") return compareNames(left, right);
const difference = left[sort] - right[sort];
return difference || compareNames(left, right);
};

const sortAssets = (assets: VehicleAsset[], sort: AssetSort, direction: SortDirection) =>
[...assets].sort((left, right) => compareAssets(left, right, sort) * (direction === "ascending" ? 1 : -1));

const AssetGlyph = ({ mode, trailer }: { mode: number; trailer: boolean }) => <svg className={styles.glyph} viewBox="0 0 24 24">
{mode === 2 ? <>
<path d="M5 15V7c0-2 2-3 7-3s7 1 7 3v8c0 2-1 3-3 3H8c-2 0-3-1-3-3Z" />
Expand All @@ -45,7 +68,11 @@ const RefreshGlyph = () => <img src={refreshIcon} alt="" className={styles.refre

export const RouteFilterUI = () => {
const [search, setSearch] = useState("");
const [visibility, setVisibility] = useState<AssetVisibility>("all");
const [sort, setSort] = useState<AssetSort>("name");
const [sortDirection, setSortDirection] = useState<SortDirection>("ascending");
const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
const [collapsedWhileFiltering, setCollapsedWhileFiltering] = useState<Set<number>>(() => new Set());
const [hovered, setHovered] = useState<VehicleAsset | null>(null);
const [page, setPage] = useState(0);
const anchor = useRef<HTMLDivElement | null>(null);
Expand All @@ -59,58 +86,74 @@ export const RouteFilterUI = () => {
const { translate } = useLocalization();
const tr = (key: string, fallback: string) => String(translate(key) ?? fallback);
const assets = useMemo(() => parseCatalog(catalogRaw), [catalogRaw]);
const selected = useMemo(() => new Set(selectedRaw.split(",").map(Number).filter(Number.isInteger)), [selectedRaw]);
const selected = useMemo(() => new Set(selectedRaw.split(",").map(value => value.trim()).filter(Boolean).map(Number).filter(Number.isInteger)), [selectedRaw]);
const normalizedSearch = search.trim().toLocaleLowerCase();
const relevant = useMemo(() => assets.filter(asset => targetTransport === 0 || (asset.mode & targetTransport) !== 0), [assets, targetTransport]);
const relevantIds = useMemo(() => new Set(relevant.map(asset => asset.id)), [relevant]);
const children = useMemo(() => {
const map = new Map<number, VehicleAsset[]>();
relevant.forEach(asset => { if (asset.parentId) map.set(asset.parentId, [...(map.get(asset.parentId) ?? []), asset]); });
relevant.forEach(asset => {
if (!asset.parentId) return;
const siblings = map.get(asset.parentId);
if (siblings) siblings.push(asset); else map.set(asset.parentId, [asset]);
});
map.forEach(siblings => siblings.sort(compareNames));
return map;
}, [relevant]);
const roots = useMemo(() => relevant.filter(asset => !asset.parentId || !relevant.some(candidate => candidate.id === asset.parentId)).filter(asset => {
if (!normalizedSearch) return true;
return asset.name.toLocaleLowerCase().includes(normalizedSearch) || (children.get(asset.id) ?? []).some(child => child.name.toLocaleLowerCase().includes(normalizedSearch));
}), [relevant, children, normalizedSearch]);
const roots = useMemo(() => sortAssets(relevant
.filter(asset => !asset.parentId || !relevantIds.has(asset.parentId))
.filter(asset => matchesCatalogFilters(asset, normalizedSearch, visibility, selected) ||
(children.get(asset.id) ?? []).some(child => matchesCatalogFilters(child, normalizedSearch, visibility, selected))),
sort, sortDirection),
[relevant, relevantIds, children, normalizedSearch, visibility, selected, sort, sortDirection]);
const selectedRelevant = relevant.filter(asset => selected.has(asset.id)).length;
const pageSize = 30;
const pageCount = Math.max(1, Math.ceil(roots.length / pageSize));
const pageIndex = Math.min(page, pageCount - 1);
const visibleRoots = roots.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
const filteringCatalog = normalizedSearch.length > 0 || visibility !== "all";

useEffect(() => setPage(0), [search, targetTransport]);
useEffect(() => setPage(0), [search, visibility, sort, sortDirection, targetTransport]);
useEffect(() => setCollapsedWhileFiltering(new Set()), [search, visibility, targetTransport]);
useEffect(() => {
if (!open) return;
return () => { trigger(mod.id, "setPointerOverUi", false); };
}, [open]);

const togglePanel = () => trigger(mod.id, "toggleTool");
const toggleExpanded = (id: number) => setExpanded(current => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const toggleExpanded = (id: number) => {
const update = (current: Set<number>) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
};
if (filteringCatalog) setCollapsedWhileFiltering(update); else setExpanded(update);
};

const renderAsset = (asset: VehicleAsset, child = false) => {
const renderAsset = (asset: VehicleAsset, child = false, contextOnly = false) => {
const childAssets = children.get(asset.id) ?? [];
const isExpanded = expanded.has(asset.id) || normalizedSearch.length > 0;
const visibleChildAssets = childAssets.filter(item => matchesCatalogFilters(item, normalizedSearch, visibility, selected));
const isExpanded = filteringCatalog ? !collapsedWhileFiltering.has(asset.id) : expanded.has(asset.id);
const groupIds = [asset.id, ...childAssets.map(item => item.id)];
const selectedCount = groupIds.filter(id => selected.has(id)).length;
const partial = childAssets.length > 0 && selectedCount > 0 && selectedCount < groupIds.length;
return <React.Fragment key={asset.id}>
<div className={classNames(styles.assetRow, { [styles.child]: child, [styles.forbidden]: selected.has(asset.id), [styles.partial]: partial })}
<div className={classNames(styles.assetRow, { [styles.child]: child, [styles.forbidden]: !contextOnly && selected.has(asset.id), [styles.partial]: !contextOnly && partial, [styles.contextOnly]: contextOnly })}
onMouseEnter={() => setHovered(asset)} onMouseOver={() => setHovered(asset)}>
{childAssets.length > 0 ? <button type="button" className={styles.expand} onClick={() => toggleExpanded(asset.id)}>{isExpanded ? "⌄" : "›"}</button> : <span className={styles.expandSpacer} />}
<button type="button" aria-label={selected.has(asset.id) ? "Allow asset" : "Forbid asset"}
{contextOnly ? <span className={styles.checkSpacer} /> : <button type="button" aria-label={selected.has(asset.id)
? tr("RouteFilter.UI.AllowAsset", "Allow asset")
: tr("RouteFilter.UI.ForbidAsset", "Forbid asset")}
className={classNames(styles.check, { [styles.checked]: selected.has(asset.id), [styles.partialCheck]: partial })}
onClick={() => childAssets.length ? trigger(mod.id, "toggleAssetGroup", asset.id, !isExpanded) : trigger(mod.id, "toggleAsset", asset.id)}>
{partial ? "−" : selected.has(asset.id) ? "×" : ""}
</button>
</button>}
<div className={styles.assetMain}>
<AssetGlyph mode={asset.mode} trailer={asset.trailer} /><em>{asset.name}</em>
{childAssets.length > 0 && <small>{childAssets.length + 1}</small>}
</div>
</div>
{childAssets.length > 0 && isExpanded && childAssets.filter(item => !normalizedSearch || item.name.toLocaleLowerCase().includes(normalizedSearch)).map(item => renderAsset(item, true))}
{childAssets.length > 0 && isExpanded && visibleChildAssets.map(item => renderAsset(item, true))}
</React.Fragment>;
};

Expand All @@ -131,6 +174,25 @@ export const RouteFilterUI = () => {
</div>
<div className={styles.assetHeader}><strong>{targetLabel}</strong><small>{selectedRelevant} / {relevant.length} {tr("RouteFilter.UI.ForbiddenCount", "forbidden")}</small></div>
<input className={styles.search} value={search} onChange={event => setSearch(event.target.value)} placeholder={tr("RouteFilter.UI.Search", "Search vehicle assets")} />
<div className={styles.catalogControls}>
<label><span>{tr("RouteFilter.UI.Show", "Show")}</span><select value={visibility} onChange={event => setVisibility(event.target.value as AssetVisibility)}>
<option value="all">{tr("RouteFilter.UI.ShowAll", "All")}</option>
<option value="forbidden">{tr("RouteFilter.UI.ShowForbidden", "Forbidden")}</option>
<option value="allowed">{tr("RouteFilter.UI.ShowAllowed", "Allowed")}</option>
</select></label>
<label><span>{tr("RouteFilter.UI.SortBy", "Sort by")}</span><select value={sort} onChange={event => setSort(event.target.value as AssetSort)}>
<option value="name">{tr("RouteFilter.UI.SortName", "Name")}</option>
<option value="maxSpeed">{tr("RouteFilter.UI.MaxSpeed", "Maximum speed")}</option>
<option value="acceleration">{tr("RouteFilter.UI.Acceleration", "Acceleration")}</option>
<option value="braking">{tr("RouteFilter.UI.Braking", "Braking")}</option>
</select></label>
<button type="button" className={styles.sortDirection}
aria-label={sortDirection === "ascending" ? tr("RouteFilter.UI.SortAscending", "Ascending") : tr("RouteFilter.UI.SortDescending", "Descending")}
title={sortDirection === "ascending" ? tr("RouteFilter.UI.SortAscending", "Ascending") : tr("RouteFilter.UI.SortDescending", "Descending")}
onClick={() => setSortDirection(current => current === "ascending" ? "descending" : "ascending")}>
{sortDirection === "ascending" ? "↑" : "↓"}
</button>
</div>
<div className={styles.details}>{hovered ? <>
<div><AssetGlyph mode={hovered.mode} trailer={hovered.trailer} /><strong>{hovered.name}</strong></div>
<span>{tr("RouteFilter.UI.MaxSpeed", "Maximum speed")}: <b>{hovered.maxSpeed} km/h</b></span>
Expand All @@ -143,7 +205,7 @@ export const RouteFilterUI = () => {
<button className={styles.refresh} aria-label={tr("RouteFilter.UI.RefreshAssets", "Refresh asset list")} onClick={() => trigger(mod.id, "refreshAssets")}><RefreshGlyph /></button>
</div>
<div className={styles.assetList} onWheel={event => { event.currentTarget.scrollTop += event.deltaY; event.stopPropagation(); }} onMouseLeave={() => setHovered(null)}>
{visibleRoots.map(asset => renderAsset(asset))}
{visibleRoots.map(asset => renderAsset(asset, false, !matchesCatalogFilters(asset, normalizedSearch, visibility, selected)))}
{roots.length === 0 && <p className={styles.empty}>{tr("RouteFilter.UI.Empty", "No matching vehicle assets")}</p>}
</div>
<div className={styles.pager}>
Expand Down