1 2import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; 3import { html } from '../html.js?v=3.8.36'; 4import { Users, Building2, Lightbulb, BookOpen, MapPin, Calendar, Search, ArrowUpDown, ChevronDown, ChevronRight, X, ExternalLink, AlertTriangle, RotateCw } from 'lucide-react'; 5import { fetchEntitiesData, getRecordsByEntity } from '../services/archiveService.js?v=3.8.36'; 6import { getEntityScope } from '../services/queryComposition.js?v=3.8.36'; 7import { COLORS, ENTITY_TYPE_CONFIG } from '../constants.js?v=3.8.36'; 8import { normalizeForSearch } from '../utils/searchNormalize.js?v=3.8.36'; 9 10// Add icons to shared config 11const TYPE_ICONS = { 12 Person: Users, 13 Organization: Building2, 14 Concept: Lightbulb, 15 Work: BookOpen, 16 Event: Calendar, 17 Location: MapPin 18}; 19 20const TYPE_CONFIG = Object.fromEntries( 21 Object.entries(ENTITY_TYPE_CONFIG).map(([type, cfg]) => [ 22 type, 23 { ...cfg, icon: TYPE_ICONS[type] || Lightbulb } 24 ]) 25); 26 27const EntityBrowser = ({ 28 records, 29 queryActive, 30 onClearQuery, 31 onSelectRecord, 32 selectedEntityId = null, 33 onSelectEntity, 34 embedded = false, 35 autoFocusSelection = true, 36}) => { 37 const [entities, setEntities] = useState([]); 38 const [loading, setLoading] = useState(true); 39 const [loadError, setLoadError] = useState(null); 40 const [selectedType, setSelectedType] = useState(null); 41 const [searchTerm, setSearchTerm] = useState(''); 42 const [sortBy, setSortBy] = useState('mentions'); 43 const [selectedEntity, setSelectedEntity] = useState(null); 44 const [entityRecords, setEntityRecords] = useState([]); 45 const [coOccurring, setCoOccurring] = useState([]); 46 const [recordEntityMap, setRecordEntityMap] = useState({}); 47 const detailHeadingRef = useRef(null); 48 const entityOpenerRef = useRef(null); 49 50 // Load entity data 51 useEffect(() => { 52 const load = async () => { 53 setLoading(true); 54 setLoadError(null);
55 try { 56 const data = await fetchEntitiesData(); 57 if (data?.error) { 58 setLoadError(data.error); 59 setEntities([]); 60 setRecordEntityMap({}); 61 } else if (data?.entities) { 62 setEntities(data.entities); 63 setRecordEntityMap(data.recordEntityMap || {}); 64 } 65 } catch (error) { 66 console.error('Error loading entity browser:', error); 67 setLoadError('The entity index could not load. Archive records remain available.'); 68 } 69 setLoading(false); 70 }; 71 load(); 72 }, []); 73 74 const { entities: scopedEntities, recordIdsByEntity } = useMemo( 75 () => getEntityScope(entities, recordEntityMap, records, queryActive), 76 [entities, recordEntityMap, records, queryActive] 77 ); 78 79 const scopedEntityById = useMemo( 80 () => new Map(scopedEntities.map(entity => [entity.id, entity])), 81 [scopedEntities] 82 ); 83 84 // Type counts 85 const typeCounts = useMemo(() => { 86 const counts = {}; 87 scopedEntities.forEach(e => { 88 counts[e.type] = (counts[e.type] || 0) + 1; 89 }); 90 return counts; 91 }, [scopedEntities]); 92 93 // Filter and sort entities 94 const filteredEntities = useMemo(() => { 95 let result = scopedEntities; 96 97 if (selectedType) { 98 result = result.filter(e => e.type === selectedType); 99 } 100 101 if (searchTerm) { 102 const term = normalizeForSearch(searchTerm); 103 result = result.filter(e => 104 normalizeForSearch(e.name).includes(term) || 105 (e.role && normalizeForSearch(e.role).includes(term)) 106 ); 107 } 108 109 if (sortBy === 'mentions') { 110 result = [...result].sort((a, b) => (b.totalMentions || 1) - (a.totalMentions || 1)); 111 } else if (sortBy === 'prominence') { 112 result = [...result].sort((a, b) => (b.prominence || 0) - (a.prominence || 0)); 113 } else { 114 result = [...result].sort((a, b) => a.name.localeCompare(b.name)); 115 } 116 117 return result; 118 }, [scopedEntities, selectedType, searchTerm, sortBy]); 119 120 // Limit displayed entities for performance 121 const [displayLimit, setDisplayLimit] = useState(100); 122 const displayedEntities = filteredEntities.slice(0, displayLimit); 123 124 // Reset display limit when filters change 125 useEffect(() => { 126 setDisplayLimit(100); 127 }, [selectedType, searchTerm, sortBy]); 128 129 const closeEntityDetails = useCallback(() => { 130 const opener = entityOpenerRef.current; 131 const selectedId = selectedEntity?.id; 132 setSelectedEntity(null); 133 setEntityRecords([]); 134 setCoOccurring([]); 135 entityOpenerRef.current = null; 136 requestAnimationFrame(() => { 137 const fallback = selectedId 138 ? document.querySelector(`[data-entity-id="${selectedId}"]`) 139 : null; 140 if (opener?.isConnected) opener.focus(); 141 else if (fallback?.isConnected) fallback.focus(); 142 else document.getElementById('entity-search')?.focus(); 143 }); 144 onSelectEntity?.(null); 145 }, [selectedEntity?.id, onSelectEntity]); 146 147 const showEntityDetails = useCallback((entity) => { 148 setSelectedEntity(entity); 149 150 // Find records that mention this entity 151 const recordIds = queryActive 152 ? (recordIdsByEntity.get(entity.id) || []) 153 : getRecordsByEntity(entity.id); 154 const matchedRecords = recordIds 155 .map(rid => records.find(r => r.id === rid)) 156 .filter(Boolean) 157 .sort((a, b) => b.date.localeCompare(a.date)); 158 setEntityRecords(matchedRecords); 159 160 // Find co-occurring entities (appear in same records) 161 const coEntityCounts = {}; 162 recordIds.forEach(rid => { 163 const entityIds = new Set(recordEntityMap[rid] || []); 164 entityIds.forEach(eid => { 165 if (eid !== entity.id) { 166 coEntityCounts[eid] = (coEntityCounts[eid] || 0) + 1; 167 } 168 }); 169 }); 170 171 const coEntities = Object.entries(coEntityCounts) 172 .map(([eid, count]) => { 173 const coEntity = scopedEntityById.get(eid); 174 return coEntity ? { ...coEntity, coCount: count } : null; 175 }) 176 .filter(Boolean) 177 .sort((a, b) => b.coCount - a.coCount) 178 .slice(0, 20); 179 180 setCoOccurring(coEntities); 181 }, [records, queryActive, recordEntityMap, recordIdsByEntity, scopedEntityById]); 182 183 // When an entity is selected, find its records and co-occurring entities. 184 // Keep the stable list opener while following co-occurrence links inside the 185 // detail panel, so closing the research path has somewhere useful to return. 186 const handleEntitySelect = useCallback((entity, opener = null) => { 187 if (selectedEntity && selectedEntity.id === entity.id) { 188 closeEntityDetails(); 189 return; 190 } 191 192 if (opener && !opener.closest('[data-entity-detail]')) { 193 entityOpenerRef.current = opener; 194 } 195 showEntityDetails(entity); 196 onSelectEntity?.(entity.id); 197 }, [selectedEntity, closeEntityDetails, showEntityDetails, onSelectEntity]); 198 199 // Re-run showEntityDetails even when the selected ID is unchanged: on a cold 200 // deep link, the entity index can resolve before the core record list, and 201 // the derived record/co-occurrence panels must refresh when that list arrives. 202 useEffect(() => { 203 if (!selectedEntityId) { 204 if (selectedEntity) { 205 entityOpenerRef.current = null; 206 setSelectedEntity(null); 207 setEntityRecords([]); 208 setCoOccurring([]); 209 } 210 return; 211 } 212 213 const entity = scopedEntityById.get(selectedEntityId); 214 if (entity) { 215 if (selectedEntityId !== selectedEntity?.id) { 216 entityOpenerRef.current = null; 217 } 218 showEntityDetails(entity); 219 } else if (!loading) { 220 onSelectEntity?.(null); 221 } 222 }, [selectedEntityId, selectedEntity?.id, scopedEntityById, showEntityDetails, loading, onSelectEntity]); 223 224 useEffect(() => { 225 if (!selectedEntity || !autoFocusSelection) return undefined; 226 // Compact layouts place the selected detail before the long result list. 227 // Moving focus makes that new context immediate for keyboard and screen- 228 // reader users, and scrolls it into view for touch users who selected a 229 // card near the top of a list that can contain hundreds of entities. 230 let focusFrame = null; 231 const layoutFrame = requestAnimationFrame(() => { 232 // DesktopShell first announces/focuses the newly active window. Enter 233 // the selected content on the following paint so a deep link or Forward 234 // traversal does not finish on chrome instead of the requested entity. 235 focusFrame = requestAnimationFrame(() => { 236 const detailPanel = detailHeadingRef.current?.closest('[data-entity-detail]'); 237 // RecordModal restores an ordinary close to its exact invoking record 238 // button inside this panel. Preserve that more precise return point; 239 // direct combined URLs have no such opener and still enter the heading. 240 if (detailPanel?.contains(document.activeElement)) return; 241 detailHeadingRef.current?.focus(); 242 }); 243 }); 244 return () => { 245 cancelAnimationFrame(layoutFrame); 246 if (focusFrame !== null) cancelAnimationFrame(focusFrame); 247 }; 248 }, [selectedEntity?.id, autoFocusSelection]); 249 250 if (loading) { 251 return html` 252 <div className="archive-data-loading" role="status" aria-l
252ive="polite"> 253 <div className="archive-data-loading__spinner" aria-hidden="true"></div> 254 <p>Loading entity data...</p> 255 </div> 256 `; 257 } 258 259 if (loadError) { 260 return html` 261 <div className="archive-notice archive-notice--danger archive-data-error" role="alert"> 262 <${AlertTriangle} aria-hidden="true" /> 263 <div> 264 <h3>Unable to load people and ideas</h3> 265 <p>${loadError}</p> 266 <button 267 type="button" 268 onClick=${() => window.location.reload()} 269 className="archive-action archive-action--danger" 270 > 271 <${RotateCw} aria-hidden="true" /> 272 Reload page 273 </button> 274 </div> 275 </div> 276 `; 277 } 278 279 // Resolve the selected entity's type styling once; the detail panel reads 280 // its bg/icon/color below and only renders when selectedEntity is set. 281 const selConfig = selectedEntity 282 ? (TYPE_CONFIG[selectedEntity.type] || TYPE_CONFIG.Concept) 283 : null; 284 const DetailHeading = embedded ? 'h3' : 'h2'; 285 const DetailSectionHeading = embedded ? 'h4' : 'h3'; 286 287 return html` 288 <div className="archive-data-surface archive-data-surface--entities"> 289 ${queryActive && html` 290 <div className="archive-notice archive-notice--warning archive-data-query-scope" role="status"> 291 <p><strong>Query scope:</strong> Counts and connections below are limited to the current query results.</p> 292 <button 293 type="button" 294 onClick=${onClearQuery} 295 className="archive-action archive-action--secondary" 296 > 297 Clear query results 298 </button> 299 </div> 300 `} 301 302 <!-- Type filter chips --> 303 <div className="archive-data-dimensions" aria-label="Entity types"> 304 <button 305 type="button" 306 onClick=${() => setSelectedType(null)} 307 aria-pressed=${!selectedType} 308 className=${`archive-data-dimension ${!selectedType ? 'is-active' : ''}`} 309 > 310 All (${scopedEntities.length}) 311 </button> 312 ${Object.entries(TYPE_CONFIG).map(([type, config]) => { 313 const count = typeCounts[type] || 0; 314 if (count === 0) return null; 315 const Icon = config.icon; 316 return html` 317 <button 318 type="button" 319 key=${type} 320 onClick=${() => setSelectedType(selectedType === type ? null : type)} 321 aria-pressed=${selectedType === type} 322 className=${`archive-data-dimension ${selectedType === type ? 'is-active' : ''}`} 323 style=${{ 324 '--archive-dimension-color': config.color, 325 '--archive-dimension-soft': config.bg, 326 }} 327 > 328 <${Icon} aria-hidden="true" /> 329 ${config.label} (${count}) 330 </button> 331 `; 332 })} 333 </div> 334
335 <!-- Search and sort controls --> 336 <div className="archive-data-toolbar"> 337 <div className="archive-data-search"> 338 <label htmlFor="entity-search" className="sr-only">Search entities</label> 339 <${Search} className="archive-data-search__icon" aria-hidden="true" /> 340 <input 341 id="entity-search" 342 type="text" 343 value=${searchTerm} 344 onInput=${(e) => setSearchTerm(e.target.value)} 345 placeholder="Search entities..." 346 className="archive-control archive-data-search__input" 347 /> 348 ${searchTerm && html` 349 <button 350 type="button" 351 onClick=${() => setSearchTerm('')} 352 className="archive-data-search__clear" 353 aria-label="Clear entity search" 354 > 355 <${X} aria-hidden="true" /> 356 </button> 357 `} 358 </div> 359 <div className="archive-data-sort"> 360 <${ArrowUpDown} aria-hidden="true" /> 361 <select 362 aria-label="Sort entities" 363 value=${sortBy} 364 onChange=${(e) => setSortBy(e.target.value)} 365 className="archive-control" 366 > 367 <option value="mentions">Most mentioned</option> 368 <option value="prominence">Highest prominence</option> 369 <option value="name">Alphabetical</option> 370 </select> 371 </div> 372 </div> 373 374 <div className="archive-data-caption" aria-live="polite"> 375 Showing ${displayedEntities.length} of ${filteredEntities.length} entities 376 </div> 377 378 <!-- Main content: entity list + detail panel --> 379 <div className=${`archive-entity-layout ${selectedEntity ? 'has-detail' : ''}`}> 380 <!-- Entity list --> 381 <div className="entity-browser-list flex-grow"> 382 <div className="archive-entity-grid"> 383 ${displayedEntities.map(entity => { 384 const config = TYPE_CONFIG[entity.type] || TYPE_CONFIG.Concept; 385 const Icon = config.icon; 386 const isSelected = selectedEntity && selectedEntity.id === entity.id; 387 const mentions = entity.totalMentions || 1; 388 389 return html` 390 <button 391 type="button" 392 key=${entity.id} 393 data-entity-id=${entity.id} 394 onClick=${(event) => handleEntitySelect(entity, event.currentTarget)} 395 aria-expanded=${Boolean(isSelected)} 396 aria-controls=${isSelected ? 'entity-detail-panel' : undefined} 397 className=${`archive-entity-row ${isSelected ? 'is-selected' : ''}`} 398 style=${{ 399 '--archive-entity-accent': config.color, 400 '--archive-entity-soft': config.bg, 401 }} 402 > 403 <div className="archive-entity-row__content"> 404 <div 405 className="archive-entity-row__icon" 406 style=${{ backgroundColor: config.bg }} 407 > 408 <${Icon} style=${{ color: config.color }} aria-hidden="true" /> 409 </div> 410 <div className="archive-entity-row__body"> 411 <div className="archive-entity-row__name"> 412 ${entity.name} 413 </div> 414 ${entity.role && entity.role !== 'None' && html` 415 <div className="archive-entity-row__role"> 416 ${entity.role} 417 </div> 418 `} 419 <div className="archive-entity-row__metrics"> 420 <span> 421 ${mentions} record${mentions !== 1 ? 's' : ''} 422 </span> 423 ${entity.prominence > 0 && html` 424 <span> 425 P:${entity.prominence} 426 </span> 427 `} 428 </div> 429 </div> 430 <${isSelected ? ChevronDown : ChevronRight} 431 className="archive-entity-row__chevron" 432 aria-hidden="true" 433 /> 434 </div> 435 </button> 436 `; 437 })} 438 </div> 439 440 ${displayedEntities.length < filteredEntities.length && html` 441 <button 442 type="button" 443 onClick=${() => setDisplayLimit(prev => prev + 100)} 444 className="archive-action archive-action--secondary archive-entity-more" 445 > 446 Show more (${filteredEntities.length - displayedEntities.length} remaining) 447 </button> 448 `} 449 </div> 450 451 <!-- Detail panel (when entity selected) --> 452 ${selectedEntity && html` 453 <div className="entity-browser-detail flex-shrink-0 archive-entity-detail"> 454 <div 455 id="entity-detail-panel" 456 data-entity-detail 457 role="region" 458 aria-labelledby="entity-detail-title" 459 onKeyDown=${(event) => { 460 if (event.key !== 'Escape') return; 461 event.preventDefault(); 462 event.stopPropagation(); 463 closeEntityDetails(); 464 }} 465 className="archive-data-panel archive-entity-detail__sheet" 466 > 467 <!-- Entity header --> 468 <div className="archive-entity-detail__header"> 469 <div className="archive-entity-detail__utility"> 470 <div className="archive-entity-detail__type"> 471 <div 472 className="archive-entity-detail__icon" 473 style=${{ backgroundColor: selConfig.bg }} 474 > 475 <${selConfig.icon} 476 style=${{ color: selConfig.color }} 477 aria-hidden="true" 478 /> 479 </div>
480 <span> 481 ${selectedEntity.type} 482 </span> 483 </div> 484 <button 485 type="button" 486 onClick=${closeEntityDetails} 487 className="archive-action archive-action--quiet archive-entity-detail__close" 488 style=${{ minWidth: '44px', minHeight: '44px' }} 489 aria-label="Close entity details" 490 > 491 <${X} aria-hidden="true" /> 492 </button> 493 </div> 494 <${DetailHeading} 495 id="entity-detail-title" 496 ref=${detailHeadingRef} 497 tabIndex="-1" 498 className="entity-detail-heading archive-entity-detail__title" 499 > 500 ${selectedEntity.name} 501 <//> 502 ${selectedEntity.role && selectedEntity.role !== 'None' && html` 503 <p className="archive-entity-detail__role">${selectedEntity.role}</p> 504 `} 505 <div className="archive-entity-detail__metrics"> 506 <span>${entityRecords.length} records</span> 507 <span>Prominence: ${selectedEntity.prominence || 0}</span> 508 </div> 509 </div> 510 511 <!-- Records mentioning this entity --> 512 <div className="archive-entity-detail__section"> 513 <${DetailSectionHeading} className="archive-data-heading"> 514 Records (${entityRecords.length}) 515 <//> 516 <div 517 className="archive-entity-detail__records archive-data-scroll" 518 role="region" 519 tabIndex="0" 520 aria-label="Records mentioning this entity" 521 > 522 ${entityRecords.slice(0, 20).map(record => html` 523 <button 524 type="button" 525 key=${record.id} 526 onClick=${(e) => { e.stopPropagation(); onSelectRecord(record.id); }} 527 className="archive-entity-record" 528 > 529 <div className="archive-entity-record__title"> 530 ${record.title} 531 </div> 532 <div className="archive-entity-record__meta"> 533 ${record.date} | ${record.pub} 534 </div> 535 </button> 536 `)} 537 ${entityRecords.length > 20 && html` 538 <div className="archive-data-caption archive-entity-record__more"> 539 +${entityRecords.length - 20} more records 540 </div> 541 `} 542 </div> 543 </div> 544 545 <!-- Co-occurring entities --> 546 ${coOccurring.length > 0 && html` 547 <div className="archive-entity-detail__section"> 548 <${DetailSectionHeading} className="archive-data-heading"> 549 Often appears with 550 <//> 551 <div className="archive-entity-relations"> 552 ${coOccurring.slice(0, 15).map(coEntity => { 553 const coConfig = TYPE_CONFIG[coEntity.type] || TYPE_CONFIG.Concept; 554 const CoIcon = coConfig.icon; 555 return html` 556 <button 557 type="button" 558 key=${coEntity.id} 559 onClick=${(event) => handleEntitySelect(coEntity, event.currentTarget)} 560 className="archive-entity-relation" 561 > 562 <${CoIcon} style=${{ color: coConfig.color }} aria-hidden="true" /> 563 <span> 564 ${coEntity.name} 565 </span> 566 <strong> 567 ${coEntity.coCount}x 568 </strong> 569 </button> 570 `; 571 })} 572 </div> 573 </div> 574 `} 575 </div> 576 </div> 577 `} 578 </div> 579 </div> 580 `; 581}; 582 583export default EntityBrowser;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.