aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xbackend/hist_data/gen_events_data.py1
-rwxr-xr-xbackend/histplorer.py47
-rw-r--r--backend/tests/test_histplorer.py14
-rw-r--r--src/App.vue75
-rw-r--r--src/components/InfoModal.vue6
-rw-r--r--src/components/SearchModal.vue207
-rw-r--r--src/components/TimeLine.vue9
-rw-r--r--src/components/icon/InfoIcon.vue8
-rw-r--r--src/index.css13
-rw-r--r--src/lib.ts14
-rw-r--r--src/store.ts27
11 files changed, 356 insertions, 65 deletions
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 @@
<h1 class="my-auto ml-2 text-4xl" :style="{color: store.color.altDark}">Histplorer</h1>
<div class="mx-auto"/> <!-- Spacer -->
<!-- Icons -->
- <icon-button :size="45" :style="buttonStyles" @click="onTimelineAdd" title="Add a timeline">
- <plus-icon/>
+ <icon-button :size="45" :style="buttonStyles">
+ <help-icon/>
</icon-button>
<icon-button :size="45" :style="buttonStyles">
<settings-icon/>
</icon-button>
- <icon-button :size="45" :style="buttonStyles">
- <help-icon/>
+ <icon-button :size="45" :style="buttonStyles" @click="onTimelineAdd" title="Add a timeline">
+ <plus-icon/>
+ </icon-button>
+ <icon-button :size="45" :style="buttonStyles" @click="searchOpen = true" title="Search">
+ <search-icon/>
</icon-button>
</div>
<!-- Content area -->
@@ -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"/>
<base-line :vert="vert" :timelines="timelines" class='m-1 sm:m-2'/>
</div>
<!-- Modals -->
<transition name="fade">
- <info-modal v-if="infoModalEvent != null && infoModalData != null"
- :event="infoModalEvent" :eventInfo="infoModalData" @close="infoModalEvent = null"/>
+ <search-modal v-if="searchOpen" :eventTree="eventTree" :titleToEvent="titleToEvent"
+ @close="searchOpen = false" @search="onSearch" @info-click="onInfoClick"/>
+ </transition>
+ <transition name="fade">
+ <info-modal v-if="infoModalData != null" :eventInfo="infoModalData" @close="infoModalData = null"/>
</transition>
</div>
</template>
@@ -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<RBTree<HistEvent>> = shallowRef(new RBTree(cmpHistEvent));
let idToEvent: Map<number, HistEvent> = new Map();
+let titleToEvent: Map<string, HistEvent> = new Map();
const unitCountMaps: Ref<Map<number, number>[]> = 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<HistEvent>, required: true},
eventInfo: {type: Object as PropType<EventInfo>, 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 @@
+<template>
+<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
+ <div class="absolute left-1/2 -translate-x-1/2 top-1/4 -translate-y-1/2 min-w-3/4 md:min-w-[12cm] flex"
+ :style="styles">
+ <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="inputRef"
+ @keyup.enter="onSearch" @keyup.esc="onClose"
+ @input="onInput" @keydown.down.prevent="onDownKey" @keydown.up.prevent="onUpKey"/>
+ <div class="p-1 hover:cursor-pointer">
+ <search-icon @click.stop="onSearch" class="w-8 h-8"/>
+ </div>
+ <div class="absolute top-[100%] w-full overflow-hidden" :style="suggContainerStyles">
+ <div v-for="(sugg, idx) of searchSuggs" :key="sugg"
+ :style="{backgroundColor: idx == focusedSuggIdx ? store.color.bgAltDark : store.color.bgAlt}"
+ class="border-b p-1 px-2 hover:underline hover:cursor-pointer flex"
+ @click="resolveSearch(sugg)">
+ <div class="grow overflow-hidden whitespace-nowrap text-ellipsis">
+ <span>{{suggDisplayStrings[idx][0]}}</span>
+ <span class="font-bold text-yellow-600">{{suggDisplayStrings[idx][1]}}</span>
+ <span>{{suggDisplayStrings[idx][2]}}</span>
+ </div>
+ <info-icon class="hover:cursor-pointer my-auto w-5 h-5"
+ @click.stop="onInfoIconClick(sugg)"/>
+ </div>
+ <div v-if="hasMoreSuggs" class="text-center">&#x2022; &#x2022; &#x2022;</div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import {ref, computed, onMounted, onUnmounted, PropType} from 'vue';
+import SearchIcon from './icon/SearchIcon.vue';
+import InfoIcon from './icon/InfoIcon.vue';
+import {HistEvent, queryServer, EventInfoJson, jsonToEventInfo, SuggResponseJson} from '../lib';
+import {useStore} from '../store';
+import {RBTree} from '../rbtree';
+
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+const inputRef = ref(null as HTMLInputElement | null);
+
+// Global store
+const store = useStore();
+
+// Props + events
+const props = defineProps({
+ eventTree: {type: Object as PropType<RBTree<HistEvent>>, required: true},
+ titleToEvent: {type: Object as PropType<Map<string, HistEvent>>, required: true},
+});
+const emit = defineEmits(['search', 'close', 'info-click']);
+
+// Search-suggestion data
+const searchSuggs = ref([] as string[]);
+const hasMoreSuggs = ref(false);
+const suggsInput = ref(''); // The input that resulted in the current suggestions (used to highlight matching text)
+const suggDisplayStrings = computed((): [string, string, string][] => {
+ let result: [string, string, string][] = [];
+ let input = suggsInput.value;
+ // Split each suggestion's text into parts before/within/after an input match
+ for (let title of searchSuggs.value){
+ let idx = title.toLowerCase().indexOf(input.toLowerCase());
+ if (idx != -1){
+ result.push([
+ title.substring(0, idx),
+ title.substring(idx, idx + input.length),
+ title.substring(idx + input.length)
+ ]);
+ } else {
+ result.push([input, '', '']);
+ }
+ }
+ return result;
+});
+const focusedSuggIdx = ref(null as null | number); // Index of a suggestion selected using the arrow keys
+
+// For server requests
+const lastReqTime = ref(0);
+const pendingReqParams = ref(null as null | URLSearchParams); // Holds data for latest request to make
+const pendingReqInput = ref(''); // Holds the user input associated with pendingReqData
+const pendingDelayedSuggReq = ref(0); // Set via setTimeout() for making a request despite a previous one still waiting
+async function onInput(){
+ let input = inputRef.value!;
+ // Check for empty input
+ if (input.value.length == 0){
+ searchSuggs.value = [];
+ hasMoreSuggs.value = false;
+ focusedSuggIdx.value = null;
+ return;
+ }
+ // Create URL params
+ let urlParams = new URLSearchParams({
+ type: 'sugg',
+ input: input.value,
+ limit: String(store.searchSuggLimit),
+ });
+ // Code for querying server
+ pendingReqParams.value = urlParams;
+ pendingReqInput.value = input.value;
+ let doReq = async () => {
+ let reqInput = pendingReqInput.value;
+ let responseObj: SuggResponseJson | null = await queryServer(pendingReqParams.value!);
+ if (responseObj == null){
+ return;
+ }
+ searchSuggs.value = responseObj.suggs;
+ hasMoreSuggs.value = responseObj.hasMore;
+ suggsInput.value = reqInput;
+ // Auto-select first result if present
+ if (searchSuggs.value.length > 0){
+ focusedSuggIdx.value = 0;
+ } else {
+ focusedSuggIdx.value = null;
+ }
+ };
+ // Query server, delaying/skipping if a request was recently sent
+ let currentTime = new Date().getTime();
+ if (lastReqTime.value == 0){
+ lastReqTime.value = currentTime;
+ await doReq();
+ if (lastReqTime.value == currentTime){
+ lastReqTime.value = 0;
+ }
+ } else if (pendingDelayedSuggReq.value == 0){
+ lastReqTime.value = currentTime;
+ pendingDelayedSuggReq.value = window.setTimeout(async () => {
+ pendingDelayedSuggReq.value = 0;
+ await doReq();
+ if (lastReqTime.value == currentTime){
+ lastReqTime.value = 0;
+ }
+ }, 300);
+ }
+}
+
+// For search events
+function onSearch(){
+ if (focusedSuggIdx.value == null){
+ let input = inputRef.value!.value.toLowerCase();
+ resolveSearch(input)
+ } else {
+ resolveSearch(searchSuggs.value[focusedSuggIdx.value]);
+ }
+}
+async function resolveSearch(eventTitle: string){
+ if (eventTitle == ''){
+ return;
+ }
+ // Check if the event data is already here
+ if (props.titleToEvent.has(eventTitle)){
+ emit('search', props.titleToEvent.get(eventTitle));
+ return;
+ }
+ // Query server for event
+ let urlParams = new URLSearchParams({type: 'info', event: eventTitle});
+ let responseObj: EventInfoJson | null = await queryServer(urlParams);
+ if (responseObj != null){
+ let eventInfo = jsonToEventInfo(responseObj);
+ emit('search', eventInfo.event);
+ } else {
+ // Trigger failure animation
+ let input = inputRef.value!;
+ input.classList.remove('animate-red-then-fade');
+ input.offsetWidth; // Triggers reflow
+ input.classList.add('animate-red-then-fade');
+ }
+}
+
+// More event handling
+function onClose(evt: Event){
+ if (evt.target == rootRef.value){
+ emit('close');
+ }
+}
+function onDownKey(){
+ if (focusedSuggIdx.value != null){
+ focusedSuggIdx.value = (focusedSuggIdx.value + 1) % searchSuggs.value.length;
+ }
+}
+function onUpKey(){
+ if (focusedSuggIdx.value != null){
+ focusedSuggIdx.value = (focusedSuggIdx.value - 1 + searchSuggs.value.length) % searchSuggs.value.length;
+ // The addition after '-1' is to avoid becoming negative
+ }
+}
+function onInfoIconClick(eventTitle: string){
+ emit('info-click', eventTitle);
+}
+
+// Focus input on mount
+onMounted(() => inputRef.value!.focus())
+
+// Styles
+const styles = computed((): Record<string,string> => {
+ let br = store.borderRadius;
+ return {
+ backgroundColor: store.color.bgAlt,
+ borderRadius: (searchSuggs.value.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`,
+ };
+});
+const suggContainerStyles = computed((): Record<string,string> => {
+ let br = store.borderRadius;
+ return {
+ backgroundColor: store.color.bgAlt,
+ borderRadius: `0 0 ${br}px ${br}px`,
+ };
+});
+</script>
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 @@
<div v-for="id in idToPos.keys()" :key="id" class="absolute animate-fadein z-20" :style="eventStyles(id)">
<!-- Image -->
<div class="rounded-full cursor-pointer hover:brightness-125" :style="eventImgStyles(id)"
- @click="onEventClick(id)"></div>
+ @click="emit('info-click', idToEvent.get(id)!.title)"></div>
<!-- Label -->
<div class="text-center text-stone-100 text-sm whitespace-nowrap text-ellipsis overflow-hidden">
{{idToEvent.get(id)!.title}}
@@ -96,7 +96,7 @@ const props = defineProps({
eventTree: {type: Object as PropType<RBTree<HistEvent>>, required: true},
unitCountMaps: {type: Object as PropType<Map<number, number>[]>, 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 @@
+<template>
+<svg viewBox="0 0 24 24" fill="none"
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <circle cx="12" cy="12" r="10"/>
+ <line x1="12" y1="16" x2="12" y2="12"/>
+ <line x1="12" y1="8" x2="12.01" y2="8"/>
+</svg>
+</template>
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,
};
},
});