fix: harden hierarchy keys and virtual navigation
This commit is contained in:
+57
-16
@@ -16,6 +16,11 @@
|
||||
return text(value).normalize('NFKD').toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function keyPart(value) {
|
||||
const encoded = encodeURIComponent(String(value));
|
||||
return `${encoded.length}:${encoded}`;
|
||||
}
|
||||
|
||||
function compareNodes(left, right) {
|
||||
const nameOrder = left.normalizedName === right.normalizedName
|
||||
? 0
|
||||
@@ -41,16 +46,24 @@
|
||||
}
|
||||
|
||||
function makeSite(id, name, ordinal, extra) {
|
||||
const syntheticType = extra && extra.syntheticType;
|
||||
return Object.assign({
|
||||
kind: 'site', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
||||
key: `site:${id}`, pendingDeletion: false, groups: [], synthetic: false,
|
||||
key: syntheticType
|
||||
? `site:s:${keyPart(syntheticType)}:${keyPart(id)}`
|
||||
: `site:t:${keyPart(id)}`,
|
||||
pendingDeletion: false, groups: [], synthetic: false,
|
||||
}, extra);
|
||||
}
|
||||
|
||||
function makeGroup(site, id, name, ordinal, extra) {
|
||||
const syntheticType = extra && extra.syntheticType;
|
||||
return Object.assign({
|
||||
kind: 'group', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
||||
key: `group:${site.id}:${id}`, parentKey: site.key, site, cameras: [],
|
||||
key: syntheticType
|
||||
? `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}`
|
||||
: `group:t:${keyPart(site.key)}:${keyPart(id)}`,
|
||||
parentKey: site.key, site, cameras: [],
|
||||
pendingDeletion: false, synthetic: false,
|
||||
}, extra);
|
||||
}
|
||||
@@ -68,8 +81,28 @@
|
||||
const groupOccurrences = new Map();
|
||||
const siteOccurrences = new Map();
|
||||
const cameraOccurrences = new Map();
|
||||
const syntheticSites = new Map();
|
||||
let syntheticOrdinal = sourceSites.length + sourceGroups.length + sourceDevices.length;
|
||||
|
||||
function registerRow(node) {
|
||||
if (!rowByKey.has(node.key)) {
|
||||
rowByKey.set(node.key, node);
|
||||
return true;
|
||||
}
|
||||
const collidedKey = node.key;
|
||||
let collisionOrdinal = 2;
|
||||
do {
|
||||
node.key = `${collidedKey}:collision:${collisionOrdinal}`;
|
||||
collisionOrdinal += 1;
|
||||
} while (rowByKey.has(node.key));
|
||||
diagnostics.push({
|
||||
code: 'row-key-collision', key: collidedKey, resolvedKey: node.key,
|
||||
kind: node.kind, id: node.canonicalId,
|
||||
});
|
||||
rowByKey.set(node.key, node);
|
||||
return false;
|
||||
}
|
||||
|
||||
sourceSites.forEach((item, ordinal) => {
|
||||
const safe = item && typeof item === 'object' ? item : {};
|
||||
const identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics);
|
||||
@@ -79,19 +112,24 @@
|
||||
source: safe,
|
||||
});
|
||||
sites.push(site);
|
||||
rowByKey.set(site.key, site);
|
||||
registerRow(site);
|
||||
if (identity.rawId && !siteByRawId.has(identity.rawId)) siteByRawId.set(identity.rawId, site);
|
||||
});
|
||||
|
||||
function ensureUnknownSite(rawSiteId) {
|
||||
const labelId = text(rawSiteId);
|
||||
const id = labelId ? `${SPECIAL.UNKNOWN_SITE_PREFIX}${labelId}` : SPECIAL.ORPHAN_SITE;
|
||||
let site = rowByKey.get(`site:${id}`);
|
||||
const syntheticType = labelId ? 'unknown-site' : 'orphan';
|
||||
const identity = `${syntheticType}:${keyPart(labelId)}`;
|
||||
let site = syntheticSites.get(identity);
|
||||
if (!site) {
|
||||
const name = labelId ? `Unknown site: ${labelId}` : 'Orphaned cameras';
|
||||
site = makeSite(id, name, syntheticOrdinal++, { synthetic: true, hierarchyStatus: labelId ? 'unknown-site' : 'orphan' });
|
||||
site = makeSite(id, name, syntheticOrdinal++, {
|
||||
synthetic: true, syntheticType, hierarchyStatus: syntheticType,
|
||||
});
|
||||
sites.push(site);
|
||||
rowByKey.set(site.key, site);
|
||||
registerRow(site);
|
||||
syntheticSites.set(identity, site);
|
||||
}
|
||||
return site;
|
||||
}
|
||||
@@ -109,23 +147,24 @@
|
||||
source: safe,
|
||||
});
|
||||
site.groups.push(group);
|
||||
rowByKey.set(group.key, group);
|
||||
registerRow(group);
|
||||
if (identity.rawId && !groupByRawId.has(identity.rawId)) groupByRawId.set(identity.rawId, group);
|
||||
});
|
||||
|
||||
function ensureGroup(site, id, name, status, template) {
|
||||
const key = `group:${site.id}:${id}`;
|
||||
const syntheticType = status === 'conflict' ? 'conflict' : status;
|
||||
const key = `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}`;
|
||||
let group = rowByKey.get(key);
|
||||
if (!group) {
|
||||
group = makeGroup(site, id, name, syntheticOrdinal++, {
|
||||
synthetic: true,
|
||||
synthetic: true, syntheticType,
|
||||
hierarchyStatus: status,
|
||||
canonicalId: template ? template.canonicalId : id,
|
||||
pendingDeletion: template ? template.pendingDeletion : false,
|
||||
source: template ? template.source : undefined,
|
||||
});
|
||||
site.groups.push(group);
|
||||
rowByKey.set(group.key, group);
|
||||
registerRow(group);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
@@ -133,6 +172,7 @@
|
||||
sourceDevices.forEach((item, ordinal) => {
|
||||
const safe = item && typeof item === 'object' ? item : {};
|
||||
const identity = createIdentity('camera', safe, ordinal, cameraOccurrences, diagnostics);
|
||||
const cameraKey = `camera:t:${keyPart(identity.uniqueId)}`;
|
||||
const directSiteId = text(safe.siteId);
|
||||
const requestedGroupId = text(safe.deviceGroupId);
|
||||
const knownGroup = requestedGroupId ? groupByRawId.get(requestedGroupId) : undefined;
|
||||
@@ -156,11 +196,11 @@
|
||||
} else if (!knownGroup) {
|
||||
group = ensureGroup(site, `${SPECIAL.UNKNOWN_GROUP_PREFIX}${requestedGroupId}`, `Unknown group: ${requestedGroupId}`, 'unknown-group');
|
||||
if (hierarchyStatus !== 'unknown-site') hierarchyStatus = 'unknown-group';
|
||||
diagnostics.push({ code: 'unknown-group', key: `camera:${identity.uniqueId}`, id: requestedGroupId });
|
||||
diagnostics.push({ code: 'unknown-group', key: cameraKey, id: requestedGroupId });
|
||||
} else if (knownGroup.site !== site) {
|
||||
group = ensureGroup(site, knownGroup.id, knownGroup.name, 'conflict', knownGroup);
|
||||
hierarchyStatus = 'conflict';
|
||||
diagnostics.push({ code: 'site-group-conflict', key: `camera:${identity.uniqueId}`, siteId: directSiteId, groupId: requestedGroupId, groupSiteId: knownGroup.site.canonicalId });
|
||||
diagnostics.push({ code: 'site-group-conflict', key: cameraKey, siteId: directSiteId, groupId: requestedGroupId, groupSiteId: knownGroup.site.canonicalId });
|
||||
} else {
|
||||
group = knownGroup;
|
||||
}
|
||||
@@ -168,15 +208,15 @@
|
||||
const name = text(safe.name) || identity.canonicalId;
|
||||
const camera = {
|
||||
kind: 'camera', id: identity.uniqueId, canonicalId: identity.canonicalId,
|
||||
key: `camera:${identity.uniqueId}`, name, normalizedName: normalized(name), ordinal,
|
||||
key: cameraKey, name, normalizedName: normalized(name), ordinal,
|
||||
parentKey: group.key, site, group, hierarchyStatus, source: safe,
|
||||
};
|
||||
camera.searchCorpus = normalized([
|
||||
name, identity.canonicalId, safe.model, safe.type, safe.address, site.name, group.name,
|
||||
].map(text).join(' '));
|
||||
group.cameras.push(camera);
|
||||
registerRow(camera);
|
||||
cameraByKey.set(camera.key, camera);
|
||||
rowByKey.set(camera.key, camera);
|
||||
});
|
||||
|
||||
sites.forEach((site) => {
|
||||
@@ -238,15 +278,16 @@
|
||||
function calculateVirtualWindow(rows, options = {}) {
|
||||
const rowHeight = Number.isFinite(options.rowHeight) && options.rowHeight > 0 ? options.rowHeight : 28;
|
||||
const viewportHeight = Number.isFinite(options.viewportHeight) && options.viewportHeight >= 0 ? options.viewportHeight : 0;
|
||||
const scrollTop = Math.max(0, Number.isFinite(options.scrollTop) ? options.scrollTop : 0);
|
||||
const requestedScrollTop = Math.max(0, Number.isFinite(options.scrollTop) ? options.scrollTop : 0);
|
||||
const overscan = Math.max(0, Number.isInteger(options.overscan) ? options.overscan : 16);
|
||||
const scrollTop = Math.min(requestedScrollTop, Math.max(0, (rows.length * rowHeight) - viewportHeight));
|
||||
const visibleStart = Math.floor(scrollTop / rowHeight);
|
||||
const visibleEnd = Math.ceil((scrollTop + viewportHeight) / rowHeight);
|
||||
const startIndex = Math.max(0, Math.min(rows.length, visibleStart - overscan));
|
||||
const endIndex = Math.max(startIndex, Math.min(rows.length, visibleEnd + overscan));
|
||||
return {
|
||||
rows: rows.slice(startIndex, endIndex), startIndex, endIndex,
|
||||
offsetTop: startIndex * rowHeight, totalHeight: rows.length * rowHeight,
|
||||
offsetTop: startIndex * rowHeight, totalHeight: rows.length * rowHeight, scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user