From 442c0bbffc5c372c7ec3510914968f75ab6e4a4f Mon Sep 17 00:00:00 2001 From: Terry Truong Date: Thu, 5 Jan 2023 17:13:03 +1100 Subject: Add partially-complete search modal For now, use placeholder code for jumping to a search result. Add db index for case-insensitive event title searching. Make type=info requests accept title instead of ID (for looking up a searched-for title). Make EventInfo contain an Event field (for showing info in search suggestions). Add titleToEvent map in App, for use by SearchModal to look up searched-for titles. Add keyboard shortcuts to open/close search and info modals. --- backend/hist_data/gen_events_data.py | 1 + backend/histplorer.py | 47 ++++---- backend/tests/test_histplorer.py | 14 ++- src/App.vue | 75 ++++++++++--- src/components/InfoModal.vue | 6 +- src/components/SearchModal.vue | 207 +++++++++++++++++++++++++++++++++++ src/components/TimeLine.vue | 9 +- src/components/icon/InfoIcon.vue | 8 ++ src/index.css | 13 +++ src/lib.ts | 14 ++- src/store.ts | 27 +++-- 11 files changed, 356 insertions(+), 65 deletions(-) create mode 100644 src/components/SearchModal.vue create mode 100644 src/components/icon/InfoIcon.vue diff --git a/backend/hist_data/gen_events_data.py b/backend/hist_data/gen_events_data.py index ee2fdc1..118b40c 100755 --- a/backend/hist_data/gen_events_data.py +++ b/backend/hist_data/gen_events_data.py @@ -178,6 +178,7 @@ def genData(wikidataFile: str, offsetsFile: str, dbFile: str, nProcs: int) -> No dbCur.execute('CREATE TABLE events (id INT PRIMARY KEY, title TEXT UNIQUE, ' \ 'start INT, start_upper INT, end INT, end_upper INT, fmt INT, ctg TEXT)') dbCur.execute('CREATE INDEX events_id_start_idx ON events(id, start)') + dbCur.execute('CREATE INDEX events_title_nocase_idx ON events(title COLLATE NOCASE)') if nProcs == 1: with bz2.open(wikidataFile, mode='rb') as file: for lineNum, line in enumerate(file, 1): diff --git a/backend/histplorer.py b/backend/histplorer.py index 86d0e2a..c20116e 100755 --- a/backend/histplorer.py +++ b/backend/histplorer.py @@ -13,7 +13,7 @@ Expected HTTP query parameters: range=-13000. means '13000 BC onwards' - scale: With type=events, specifies a date scale - incl: With type=events, specifies an event to include, as an event ID -- event: With type=info, specifies the event ID to get info for +- event: With type=info, specifies the event title to get info for - input: With type=sugg, specifies a search string to suggest for - limit: With type=events or type=sugg, specifies the max number of results - ctg: With type=events or type=sugg, specifies an event category to restrict results to @@ -91,14 +91,15 @@ class ImgInfo: return str(self.__dict__) class EventInfo: """ Used when responding to type=info requests """ - def __init__(self, desc: str, wikiId: int, imgInfo: ImgInfo): + def __init__(self, event: Event, desc: str, wikiId: int, imgInfo: ImgInfo): + self.event = event self.desc = desc self.wikiId = wikiId self.imgInfo = imgInfo # Used in unit testing def __eq__(self, other): return isinstance(other, EventInfo) and \ - (self.desc, self.wikiId, self.imgInfo) == (other.desc, other.wikiId, other.imgInfo) + (self.event, self.desc, self.wikiId, self.imgInfo) == (other.event, other.desc, other.wikiId, other.imgInfo) def __repr__(self): return str(self.__dict__) class SuggResponse: @@ -109,7 +110,7 @@ class SuggResponse: # Used in unit testing def __eq__(self, other): return isinstance(other, SuggResponse) and \ - (set(self.suggs), self.hasMore) == (set(other.suggs), other.hasMore) + (self.suggs, self.hasMore) == (other.suggs, other.hasMore) def __repr__(self): return str(self.__dict__) @@ -268,8 +269,8 @@ def eventEntryToResults( if n is not None: newDates[i] = dbDateToHistDate(n, fmt, i < 2) # - return Event(eventId, title, newDates[0], newDates[1], newDates[2], newDates[3], ctg, 0, pop) #return Event(eventId, title, newDates[0], newDates[1], newDates[2], newDates[3], ctg, imageId, pop) + return Event(eventId, title, newDates[0], newDates[1], newDates[2], newDates[3], ctg, 0, pop) def lookupUnitCounts( start: HistDate | None, end: HistDate | None, scale: int, dbCur: sqlite3.Cursor) -> dict[int, int] | None: # Build query @@ -294,23 +295,26 @@ def handleInfoReq(params: dict[str, str], dbCur: sqlite3.Cursor): if 'event' not in params: print('INFO: No \'event\' parameter for type=info request', file=sys.stderr) return None - try: - eventId = int(params['event']) - except ValueError: - print('INFO: Invalid value for \'event\' parameter', file=sys.stderr) - return None - return lookupEventInfo(eventId, dbCur) -def lookupEventInfo(eventId: int, dbCur: sqlite3.Cursor) -> EventInfo | None: - """ Look up an event with given ID, and return a descriptive EventInfo """ - return EventInfo(f'DESC {eventId}', 1, ImgInfo(f'http://example.org/{eventId}', 'license', 'artist', 'credit')) - #query = 'SELECT desc, wiki_id, url, license, artist, credit FROM events' \ + return lookupEventInfo(params['event'], dbCur) +def lookupEventInfo(eventTitle: str, dbCur: sqlite3.Cursor) -> EventInfo | None: + """ Look up an event with given title, and return a descriptive EventInfo """ + return EventInfo( + Event(1, eventTitle, HistDate(True, 2000, 10, 1), None, None, None, 'event', 10, 100), + f'DESC for {eventTitle}', 1, ImgInfo(f'http://example.org/{eventTitle}', 'license', 'artist', 'credit')) + #query = \ + # 'SELECT events.id, title, start, start_upper, end, end_upper, fmt, ctg, images.id, pop.pop, ' \ + # ' descs.desc, descs.wiki_id, ' \ + # ' images.url, images.license, images.artist, images.credit FROM events' \ + # ' INNER JOIN pop ON events.id = pop.id' \ # ' INNER JOIN descs ON events.id = descs.id' \ - # ' INNER JOIN event_imgs ON events.id = event_imgs.id INNER JOIN images ON event_imgs.img_id = images.id' \ - # ' WHERE events.id = ?' - #row = dbCur.execute(query, (eventId,)).fetchone() + # ' INNER JOIN event_imgs ON events.id = event_imgs.id' \ + # ' INNER JOIN images ON event_imgs.img_id = images.id' \ + # ' WHERE events.title = ? COLLATE NOCASE' + #row = dbCur.execute(query, (eventTitle,)).fetchone() #if row is not None: - # desc, wikiId, url, license, artist, credit = row - # return EventInfo(desc, wikiId, ImgInfo(url, license, artist, credit)) + # event = eventEntryToResults(row[:10]) + # desc, wikiId, url, license, artist, credit = row[10:] + # return EventInfo(event, desc, wikiId, ImgInfo(url, license, artist, credit)) #else: # return None @@ -350,12 +354,13 @@ def lookupSuggs(searchStr: str, resultLimit: int, ctg: str | None, dbCur: sqlite for (title,) in dbCur.execute(query, params): suggs.append(title) # If insufficient results, try substring search - existing = set(suggs) if len(suggs) < tempLimit: + existing = set(suggs) params = ['%' + searchStr + '%'] + ([ctg] if ctg is not None else []) for (title,) in dbCur.execute(query, params): if title not in existing: suggs.append(title) if len(suggs) == tempLimit: break + # return SuggResponse(suggs[:resultLimit], len(suggs) > resultLimit) diff --git a/backend/tests/test_histplorer.py b/backend/tests/test_histplorer.py index b3f7cb4..fcaafb5 100644 --- a/backend/tests/test_histplorer.py +++ b/backend/tests/test_histplorer.py @@ -124,12 +124,18 @@ class TestHandleReq(unittest.TestCase): ]) self.assertEqual(response.unitCounts, {-2000: 1, 1900: 2, 1990: 1}) def test_info_req(self): - response = handleReq(self.dbFile, {'QUERY_STRING': 'type=info&event=3'}) + response = handleReq(self.dbFile, {'QUERY_STRING': 'type=info&event=event%20three'}) self.assertEqual(response, - EventInfo('desc three', 300, ImgInfo('example.com/3', 'cc-by-sa 3.0', 'artist three', 'credits three'))) - response = handleReq(self.dbFile, {'QUERY_STRING': 'type=info&event=4'}) + EventInfo( + Event(3, 'event three', HistDate(True, 1990, 10, 10), HistDate(True, 2000, 10, 10), None, None, + 'discovery', 30, 0), + 'desc three', 300, ImgInfo('example.com/3', 'cc-by-sa 3.0', 'artist three', 'credits three'))) + response = handleReq(self.dbFile, {'QUERY_STRING': 'type=info&event=event%20four'}) self.assertEqual(response, - EventInfo('desc four', 400, ImgInfo('example.com/2', 'cc-by', 'artist two', 'credits two'))) + EventInfo( + Event(4, 'event four', HistDate(False, -2000, 10, 10), None, HistDate(False, 1, 10, 10), None, + 'event', 20, 1000), + 'desc four', 400, ImgInfo('example.com/2', 'cc-by', 'artist two', 'credits two'))) def test_sugg_req(self): response = handleReq(self.dbFile, {'QUERY_STRING': 'type=sugg&input=event t'}) self.assertEqual(response, SuggResponse(['event two', 'event three'], False)) diff --git a/src/App.vue b/src/App.vue index 80ce803..2f5051a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,14 +5,17 @@

Histplorer

- - + + - - + + + + +
@@ -23,13 +26,16 @@ :eventTree="eventTree" :unitCountMaps="unitCountMaps" class="grow basis-full min-h-0 outline outline-1" @close="onTimelineClose(idx)" @state-chg="onTimelineChg($event, idx)" @event-display="onEventDisplay" - @event-click="onEventClick"/> + @info-click="onInfoClick"/> - + + + + @@ -40,11 +46,13 @@ import {ref, computed, onMounted, onUnmounted, Ref, shallowRef, ShallowRef} from import TimeLine from './components/TimeLine.vue'; import BaseLine from './components/BaseLine.vue'; import InfoModal from './components/InfoModal.vue'; +import SearchModal from './components/SearchModal.vue'; import IconButton from './components/IconButton.vue'; // Icons -import PlusIcon from './components/icon/PlusIcon.vue'; -import SettingsIcon from './components/icon/SettingsIcon.vue'; import HelpIcon from './components/icon/HelpIcon.vue'; +import SettingsIcon from './components/icon/SettingsIcon.vue'; +import PlusIcon from './components/icon/PlusIcon.vue'; +import SearchIcon from './components/icon/SearchIcon.vue'; // Other import {HistDate, HistEvent, queryServer, EventResponseJson, jsonToHistEvent, SCALES, stepDate, TimelineState, cmpHistEvent, dateToUnit, DateRangeTree, @@ -110,6 +118,7 @@ function onTimelineClose(idx: number){ // For storing and looking up events const eventTree: ShallowRef> = shallowRef(new RBTree(cmpHistEvent)); let idToEvent: Map = new Map(); +let titleToEvent: Map = new Map(); const unitCountMaps: Ref[]> = ref(SCALES.map(() => new Map())); // For each scale, maps units to event counts // For keeping event data under a memory limit @@ -149,6 +158,10 @@ function reduceEvents(){ eventTree.value = newTree; unitCountMaps.value = newMaps; idToEvent = eventsToKeep; + titleToEvent = new Map(); + for (let event of eventsToKeep.values()){ + titleToEvent.set(event.title, event); + } } // For getting events from server const EVENT_REQ_LIMIT = 300; @@ -214,6 +227,7 @@ async function onEventDisplay( }); let responseObj: EventResponseJson | null = await queryServer(urlParams); if (responseObj == null){ + console.log('WARNING: Server gave null response to event query'); return; } queriedRanges[scaleIdx].add([firstDate, lastDate]); @@ -225,6 +239,7 @@ async function onEventDisplay( if (success){ eventAdded = true; idToEvent.set(event.id, event); + titleToEvent.set(event.title, event); } } // Collect unit counts @@ -260,18 +275,25 @@ async function onEventDisplay( } // For info modal -const infoModalEvent = ref(null as HistEvent | null); const infoModalData = ref(null as EventInfo | null); -async function onEventClick(eventId: number){ +async function onInfoClick(eventTitle: string){ // Query server for event info - let urlParams = new URLSearchParams({type: 'info', event: String(eventId)}); + let urlParams = new URLSearchParams({type: 'info', event: eventTitle}); let responseObj: EventInfoJson | null = await queryServer(urlParams); if (responseObj != null){ - infoModalEvent.value = idToEvent.get(eventId)!; infoModalData.value = jsonToEventInfo(responseObj); + } else { + console.log('WARNING: Server gave null response to info query'); } } +// For search modal +const searchOpen = ref(false); +function onSearch(event: HistEvent){ + searchOpen.value = false; + console.log(`Need to jump to event "${event.title}" at ${event.start}`); +} + // For resize handling let lastResizeHdlrTime = 0; // Used to throttle resize handling let afterResizeHdlr = 0; // Used to trigger handler after ending a run of resize events @@ -298,6 +320,33 @@ async function onResize(){ onMounted(() => window.addEventListener('resize', onResize)); onUnmounted(() => window.removeEventListener('resize', onResize)); +// For keyboard shortcuts +function onKeyDown(evt: KeyboardEvent){ + if (store.disableShortcuts){ + return; + } + if (evt.key == 'Escape'){ + if (infoModalData.value != null){ + infoModalData.value = null; + } else if (searchOpen.value){ + searchOpen.value = false; + } + } else if (evt.key == 'f' && evt.ctrlKey){ + evt.preventDefault(); + // Open/focus search bar + if (!searchOpen.value){ + searchOpen.value = true; + } + } +} +onMounted(() => { + window.addEventListener('keydown', onKeyDown); + // Note: Need 'keydown' instead of 'keyup' to override default CTRL-F +}); +onUnmounted(() => { + window.removeEventListener('keydown', onKeyDown); +}); + // Styles const buttonStyles = computed(() => ({ color: store.color.text, diff --git a/src/components/InfoModal.vue b/src/components/InfoModal.vue index 6ab2bde..3e03187 100644 --- a/src/components/InfoModal.vue +++ b/src/components/InfoModal.vue @@ -74,7 +74,7 @@ import SCollapsible from './SCollapsible.vue'; import CloseIcon from './icon/CloseIcon.vue'; import DownIcon from './icon/DownIcon.vue'; import ExternalLinkIcon from './icon/ExternalLinkIcon.vue'; -import {HistEvent, EventInfo} from '../lib'; +import {EventInfo} from '../lib'; import {useStore} from '../store'; // Refs @@ -86,14 +86,14 @@ const store = useStore(); // Props + events const props = defineProps({ - event: {type: Object as PropType, required: true}, eventInfo: {type: Object as PropType, required: true}, }); const emit = defineEmits(['close']); // For data display +const event = computed(() => props.eventInfo.event) const datesDisplayStr = computed(() => { - return props.event.start.toString() + (props.event.end == null ? '' : ' to ' + props.event.end.toString()) + return event.value.start.toString() + (event.value.end == null ? '' : ' to ' + event.value.end.toString()) }); function licenseToUrl(license: string){ license = license.toLowerCase().replaceAll('-', ' '); diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue new file mode 100644 index 0000000..65d2496 --- /dev/null +++ b/src/components/SearchModal.vue @@ -0,0 +1,207 @@ + + + diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue index 277a263..e5fbf34 100644 --- a/src/components/TimeLine.vue +++ b/src/components/TimeLine.vue @@ -48,7 +48,7 @@
+ @click="emit('info-click', idToEvent.get(id)!.title)">
{{idToEvent.get(id)!.title}} @@ -96,7 +96,7 @@ const props = defineProps({ eventTree: {type: Object as PropType>, required: true}, unitCountMaps: {type: Object as PropType[]>, required: true}, }); -const emit = defineEmits(['close', 'state-chg', 'event-display', 'event-click']); +const emit = defineEmits(['close', 'state-chg', 'event-display', 'info-click']); // For size tracking const width = ref(0); @@ -1109,11 +1109,6 @@ watch(firstDate, onStateChg); const skipTransition = ref(true); onMounted(() => setTimeout(() => {skipTransition.value = false}, 100)); -// Click handling -function onEventClick(eventId: number){ - emit('event-click', eventId); -} - // Styles const mainlineStyles = computed(() => { return { diff --git a/src/components/icon/InfoIcon.vue b/src/components/icon/InfoIcon.vue new file mode 100644 index 0000000..47a14cd --- /dev/null +++ b/src/components/icon/InfoIcon.vue @@ -0,0 +1,8 @@ + diff --git a/src/index.css b/src/index.css index e050c82..e16c538 100644 --- a/src/index.css +++ b/src/index.css @@ -25,6 +25,19 @@ opacity: 1; } } +.animate-red-then-fade { + animation-name: red-then-fade; + animation-duration: 500ms; + animation-timing-function: ease-in; +} +@keyframes red-then-fade { + from { + background-color: rgba(255,0,0,0.2); + } + to { + background-color: transparent; + } +} /* Other */ @font-face { diff --git a/src/lib.ts b/src/lib.ts index 6f1a1fe..f6147dc 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -280,10 +280,12 @@ export class ImgInfo { } } export class EventInfo { + event: HistEvent; desc: string; wikiId: number; imgInfo: ImgInfo; - constructor(desc: string, wikiId: number, imgInfo: ImgInfo){ + constructor(event: HistEvent, desc: string, wikiId: number, imgInfo: ImgInfo){ + this.event = event; this.desc = desc; this.wikiId = wikiId; this.imgInfo = imgInfo; @@ -310,9 +312,6 @@ export async function queryServer(params: URLSearchParams, serverDataUrl=SERVER_ console.log(`Error with querying ${url.toString()}: ${error}`); return null; } - if (responseObj == null){ - console.log('WARNING: Server gave null response'); - } return responseObj; } export function getImagePath(imgId: number): string { @@ -341,6 +340,7 @@ export type EventResponseJson = { unitCounts: {[x: number]: number} | null, } export type EventInfoJson = { + event: HistEventJson, desc: string, wikiId: number, imgInfo: ImgInfoJson, @@ -351,6 +351,10 @@ export type ImgInfoJson = { artist: string, credit: string, } +export type SuggResponseJson = { + suggs: string[], + hasMore: boolean, +} export function jsonToHistDate(json: HistDateJson): HistDate { return new HistDate(json.gcal, json.year, json.month, json.day); } @@ -368,7 +372,7 @@ export function jsonToHistEvent(json: HistEventJson): HistEvent { ); } export function jsonToEventInfo(json: EventInfoJson): EventInfo { - return new EventInfo(json.desc, json.wikiId, jsonToImgInfo(json.imgInfo)); + return new EventInfo(jsonToHistEvent(json.event), json.desc, json.wikiId, jsonToImgInfo(json.imgInfo)); } export function jsonToImgInfo(json: ImgInfoJson): ImgInfo { return new ImgInfo(json.url, json.license, json.artist, json.credit); diff --git a/src/store.ts b/src/store.ts index 1d51cc7..393e4b8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -8,19 +8,20 @@ import {CalDate} from './lib'; export const useStore = defineStore('store', { state: () => { const color = { // Note: For scrollbar colors on chrome, edit ./index.css - text: '#fafaf9', // stone-50 - textDark: '#a8a29e', // stone-400 - bg: '#292524', // stone-800 - bgLight: '#44403c', // stone-700 - bgDark: '#1c1917', // stone-900 - bgLight2: '#57534e', // stone-600 - bgDark2: '#0e0c0b', // darker version of stone-900 - alt: '#fde047', // yellow-300 - altDark: '#eab308', // yellow-500 - altDark2: '#ca8a04', // yellow-600 + text: '#fafaf9', // stone-50 + textDark: '#a8a29e', // stone-400 + bg: '#292524', // stone-800 + bgLight: '#44403c', // stone-700 + bgDark: '#1c1917', // stone-900 + bgLight2: '#57534e', // stone-600 + bgDark2: '#0e0c0b', // darker version of stone-900 + alt: '#fde047', // yellow-300 + altDark: '#eab308', // yellow-500 + altDark2: '#ca8a04', // yellow-600 altBg: '#6a5e2e', - alt2: '#2563eb', // sky-600 - bgAlt: '#f5f5f4', // stone-100 + alt2: '#2563eb', // sky-600 + bgAlt: '#f5f5f4', // stone-100 + bgAltDark: '#d6d3d1', // stone-300 }; return { tickLen: 16, @@ -46,6 +47,8 @@ export const useStore = defineStore('store', { showEventCounts: true, transitionDuration: 300, borderRadius: 5, // px + searchSuggLimit: 10, + disableShortcuts: false, }; }, }); -- cgit v1.2.3