diff options
| author | Terry Truong <terry06890@gmail.com> | 2023-01-04 23:55:10 +1100 |
|---|---|---|
| committer | Terry Truong <terry06890@gmail.com> | 2023-01-04 23:55:10 +1100 |
| commit | f93a728091e52ae5144a51fb6203fde8cdf02558 (patch) | |
| tree | 3de78eda743a8064212dda25dcc690371d9413f7 | |
| parent | 969a5351529971180748ef1a6c41b22da87b5af1 (diff) | |
Add event info modal
Add InfoModal.vue, SCollapsible.vue, and icons.
Update Timeline.vue, App.vue, lib.ts, and store.ts to display modal.
For testing, send/use dummy EventInfo from server (still waiting on image downloads).
| -rwxr-xr-x | backend/histplorer.py | 33 | ||||
| -rw-r--r-- | src/App.vue | 29 | ||||
| -rw-r--r-- | src/components/InfoModal.vue | 152 | ||||
| -rw-r--r-- | src/components/SCollapsible.vue | 61 | ||||
| -rw-r--r-- | src/components/TimeLine.vue | 16 | ||||
| -rw-r--r-- | src/components/icon/DownIcon.vue | 6 | ||||
| -rw-r--r-- | src/components/icon/ExternalLinkIcon.vue | 8 | ||||
| -rw-r--r-- | src/lib.ts | 44 | ||||
| -rw-r--r-- | src/store.ts | 2 |
9 files changed, 325 insertions, 26 deletions
diff --git a/backend/histplorer.py b/backend/histplorer.py index 6e7e340..86d0e2a 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 to get info for +- event: With type=info, specifies the event ID 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 @@ -23,7 +23,7 @@ from typing import Iterable import sys, re import urllib.parse, sqlite3 import gzip, jsonpickle -from hist_data.cal import gregorianToJdn, HistDate, MIN_CAL_YEAR, dbDateToHistDate, dateToUnit +from hist_data.cal import HistDate, dbDateToHistDate, dateToUnit DB_FILE = 'hist_data/data.db' MAX_REQ_EVENTS = 500 @@ -91,7 +91,7 @@ class ImgInfo: return str(self.__dict__) class EventInfo: """ Used when responding to type=info requests """ - def __init__(self, desc: str, wikiId: str, imgInfo: ImgInfo): + def __init__(self, desc: str, wikiId: int, imgInfo: ImgInfo): self.desc = desc self.wikiId = wikiId self.imgInfo = imgInfo @@ -220,14 +220,14 @@ def lookupEvents(start: HistDate | None, end: HistDate | None, scale: int, ctg: # Constrain by start/end startUnit = dateToUnit(start, scale) if start is not None else None endUnit = dateToUnit(end, scale) if end is not None else None - if start is not None and startUnit == endUnit: + if startUnit is not None and startUnit == endUnit: constraints.append('event_disp.unit = ?') params.append(startUnit) else: - if start is not None: + if startUnit is not None: constraints.append('event_disp.unit >= ?') params.append(startUnit) - if end is not None: + if endUnit is not None: constraints.append('event_disp.unit < ?') params.append(endUnit) # Constrain by event category @@ -302,16 +302,17 @@ def handleInfoReq(params: dict[str, str], dbCur: sqlite3.Cursor): 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 """ - query = 'SELECT desc, wiki_id, url, license, artist, credit FROM events' \ - ' 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() - if row is not None: - desc, wikiId, url, license, artist, credit = row - return EventInfo(desc, wikiId, ImgInfo(url, license, artist, credit)) - else: - return None + 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' \ + # ' 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() + #if row is not None: + # desc, wikiId, url, license, artist, credit = row + # return EventInfo(desc, wikiId, ImgInfo(url, license, artist, credit)) + #else: + # return None # For type=sugg def handleSuggReq(params: dict[str, str], dbCur: sqlite3.Cursor): diff --git a/src/App.vue b/src/App.vue index 11fb7cc..80ce803 100644 --- a/src/App.vue +++ b/src/App.vue @@ -22,9 +22,15 @@ :vert="vert" :initialState="state" :closeable="timelines.length > 1" :eventTree="eventTree" :unitCountMaps="unitCountMaps" class="grow basis-full min-h-0 outline outline-1" - @remove="onTimelineRemove(idx)" @state-chg="onTimelineChg($event, idx)" @event-display="onEventDisplay"/> + @close="onTimelineClose(idx)" @state-chg="onTimelineChg($event, idx)" @event-display="onEventDisplay" + @event-click="onEventClick"/> <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"/> + </transition> </div> </template> @@ -33,6 +39,7 @@ import {ref, computed, onMounted, onUnmounted, Ref, shallowRef, ShallowRef} from // Components import TimeLine from './components/TimeLine.vue'; import BaseLine from './components/BaseLine.vue'; +import InfoModal from './components/InfoModal.vue'; import IconButton from './components/IconButton.vue'; // Icons import PlusIcon from './components/icon/PlusIcon.vue'; @@ -40,7 +47,8 @@ import SettingsIcon from './components/icon/SettingsIcon.vue'; import HelpIcon from './components/icon/HelpIcon.vue'; // Other import {HistDate, HistEvent, queryServer, EventResponseJson, jsonToHistEvent, - SCALES, stepDate, TimelineState, cmpHistEvent, dateToUnit, DateRangeTree} from './lib'; + SCALES, stepDate, TimelineState, cmpHistEvent, dateToUnit, DateRangeTree, + EventInfo, EventInfoJson, jsonToEventInfo} from './lib'; import {useStore} from './store'; import {RBTree, rbtree_shallow_copy} from './rbtree'; @@ -91,7 +99,7 @@ function onTimelineAdd(){ } addTimeline(); } -function onTimelineRemove(idx: number){ +function onTimelineClose(idx: number){ if (timelines.value.length == 1){ console.log('Ignored removal of last timeline') return; @@ -204,7 +212,7 @@ async function onEventDisplay( scale: String(SCALES[scaleIdx]), limit: String(EVENT_REQ_LIMIT), }); - let responseObj: EventResponseJson = await queryServer(urlParams); + let responseObj: EventResponseJson | null = await queryServer(urlParams); if (responseObj == null){ return; } @@ -251,6 +259,19 @@ async function onEventDisplay( }, SERVER_QUERY_TIMEOUT); } +// For info modal +const infoModalEvent = ref(null as HistEvent | null); +const infoModalData = ref(null as EventInfo | null); +async function onEventClick(eventId: number){ + // Query server for event info + let urlParams = new URLSearchParams({type: 'info', event: String(eventId)}); + let responseObj: EventInfoJson | null = await queryServer(urlParams); + if (responseObj != null){ + infoModalEvent.value = idToEvent.get(eventId)!; + infoModalData.value = jsonToEventInfo(responseObj); + } +} + // 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 diff --git a/src/components/InfoModal.vue b/src/components/InfoModal.vue new file mode 100644 index 0000000..6ab2bde --- /dev/null +++ b/src/components/InfoModal.vue @@ -0,0 +1,152 @@ +<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/2 -translate-y-1/2 + max-w-[80%] w-2/3 min-w-[8cm] md:w-[14cm] lg:w-[16cm] max-h-[80%]" :style="styles"> + <close-icon @click.stop="onClose" ref="closeRef" + class="absolute top-1 right-1 md:top-2 md:right-2 w-8 h-8 hover:cursor-pointer"/> + <h1 class="text-center text-xl font-bold pt-2 pb-1 md:text-2xl md:pt-3 md:pb-1"> + {{event.title}} + </h1> + <p class="text-center text-sm md:text-base">{{datesDisplayStr}}</p> + <div class="border-t border-stone-400 p-2 md:p-3"> + <div class="mt-1 mr-2 md:mb-2 md:mr-4 md:float-left"> + <!-- Image --> + <a :href="eventInfo.imgInfo.url" target="_blank" class="block w-fit mx-auto" :style="imgStyles"></a> + <!-- Image Source --> + <s-collapsible class="text-sm text-center w-fit max-w-full md:max-w-[200px] mx-auto"> + <template v-slot:summary="slotProps"> + <div class="py-1 hover:underline"> + <down-icon class="inline-block w-4 h-4 mr-1 transition-transform duration-300" + :class="{'-rotate-90': slotProps.open}"/> + Image Source + </div> + </template> + <template v-slot:content> + <ul class="rounded overflow-x-auto p-1" + :style="{backgroundColor: store.color.bg, color: store.color.text}"> + <li> + <span :style="{color: store.color.altDark}">Source: </span> + <a :href="eventInfo.imgInfo.url" target="_blank">Link</a> + <external-link-icon class="inline-block w-3 h-3 ml-1"/> + </li> + <li class="whitespace-nowrap"> + <span :style="{color: store.color.altDark}">Artist: </span> + {{eventInfo.imgInfo.artist}} + </li> + <li v-if="eventInfo.imgInfo.credit != ''" class="whitespace-nowrap"> + <span :style="{color: store.color.altDark}">Credits: </span> + {{eventInfo.imgInfo.credit}} + </li> + <li> + <span :style="{color: store.color.altDark}">License: </span> + <a :href="licenseToUrl(eventInfo.imgInfo.license)" target="_blank"> + {{eventInfo.imgInfo.license}} + </a> + <external-link-icon class="inline-block w-3 h-3 ml-1"/> + </li> + <li> + <span :style="{color: store.color.altDark}">Obtained via: </span> + <a href="https://www.wikipedia.org/">Wikipedia</a> + <external-link-icon class="inline-block w-3 h-3 ml-1"/> + </li> + <li> + <span :style="{color: store.color.altDark}">Changes: </span> + Cropped and resized + </li> + </ul> + </template> + </s-collapsible> + </div> + <div>{{eventInfo.desc}}</div> + <div class="text-sm text-right"> + <a :href="'https://en.wikipedia.org/?curid=' + eventInfo.wikiId" target="_blank">From Wikipedia</a> + (via <a :href="'https://www.wikidata.org/wiki/Q' + event.id" target="_blank">Wikidata</a>) + <external-link-icon class="inline-block w-3 h-3 ml-1"/> + </div> + </div> + </div> +</div> +</template> + +<script setup lang="ts"> +import {ref, computed, PropType} from 'vue'; +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 {useStore} from '../store'; + +// Refs +const rootRef = ref(null as HTMLDivElement | null); +const closeRef = ref(null as typeof CloseIcon | null); + +// Global store +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 datesDisplayStr = computed(() => { + return props.event.start.toString() + (props.event.end == null ? '' : ' to ' + props.event.end.toString()) +}); +function licenseToUrl(license: string){ + license = license.toLowerCase().replaceAll('-', ' '); + if (license == 'cc0'){ + return 'https://creativecommons.org/publicdomain/zero/1.0/'; + } else if (license == 'cc publicdomain'){ + return 'https://creativecommons.org/licenses/publicdomain/'; + } else { + const regex = /cc by( nc)?( sa)?( ([0-9.]+)( [a-z]+)?)?/; + let results = regex.exec(license); + if (results != null){ + let url = 'https://creativecommons.org/licenses/by'; + if (results[1] != null){ + url += '-nc'; + } + if (results[2] != null){ + url += '-sa'; + } + if (results[4] != null){ + url += '/' + results[4]; + } else { + url += '/4.0'; + } + if (results[5] != null){ + url += '/' + results[5].substring(1); + } + return url; + } + return "[INVALID LICENSE]"; + } +} + +// Close handling +function onClose(evt: Event){ + if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){ + emit('close'); + } +} + +// Styles +const styles = computed(() => ({ + backgroundColor: store.color.bgAlt, + borderRadius: store.borderRadius + 'px', + overflow: 'visible auto', +})); +const imgStyles = computed(() => { + return { + width: '200px', + height: '200px', + //backgroundImage: + backgroundColor: store.color.bgDark, + //backgroundSize: 'cover', + borderRadius: store.borderRadius + 'px', + }; +}); +</script> diff --git a/src/components/SCollapsible.vue b/src/components/SCollapsible.vue new file mode 100644 index 0000000..39b4283 --- /dev/null +++ b/src/components/SCollapsible.vue @@ -0,0 +1,61 @@ +<template> +<div :style="styles"> + <div class="hover:cursor-pointer" @click="onClick"> + <slot name="summary" :open="open">(Summary)</slot> + </div> + <transition @enter="onEnter" @after-enter="onAfterEnter" @leave="onLeave" @before-leave="onBeforeLeave"> + <div v-show="open" :style="contentStyles" class="max-h-0" ref="content"> + <slot name="content">(Content)</slot> + </div> + </transition> +</div> +</template> + +<script setup lang="ts"> +import {ref, computed, watch} from 'vue'; + +// Props + events +const props = defineProps({ + modelValue: {type: Boolean, default: false}, // For using v-model on the component +}); +const emit = defineEmits(['update:modelValue', 'open']); + +// For open status +const open = ref(false); +watch(() => props.modelValue, (newVal) => {open.value = newVal}) +function onClick(){ + open.value = !open.value; + emit('update:modelValue', open.value); + if (open.value){ + emit('open'); + } +} + +// Styles +const styles = computed(() => ({ + overflow: open.value ? 'visible' : 'hidden', +})); +const contentStyles = computed(() => ({ + overflow: 'hidden', + opacity: open.value ? '1' : '0', + transitionProperty: 'max-height, opacity', + transitionDuration: '300ms', + transitionTimingFunction: 'ease-in-out', +})); + +// Open/close transitions +function onEnter(el: HTMLDivElement){ + el.style.maxHeight = el.scrollHeight + 'px'; +} +function onAfterEnter(el: HTMLDivElement){ + el.style.maxHeight = 'none'; + // Allows the content to grow after the transition ends, as the scrollHeight sometimes is too short +} +function onBeforeLeave(el: HTMLDivElement){ + el.style.maxHeight = el.scrollHeight + 'px'; + el.offsetWidth; // Triggers reflow +} +function onLeave(el: HTMLDivElement){ + el.style.maxHeight = '0'; +} +</script> diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue index fea8fb3..277a263 100644 --- a/src/components/TimeLine.vue +++ b/src/components/TimeLine.vue @@ -1,5 +1,5 @@ <template> -<div class="touch-none relative overflow-hidden" ref="rootRef" +<div class="touch-none relative overflow-hidden z-0" ref="rootRef" @wheel.exact.prevent="onWheel" @wheel.shift.exact.prevent="onShiftWheel"> <template v-if="store.showEventCounts"> <div v-for="[tickIdx, count] in tickToCount.entries()" :key="ticks[tickIdx].date.toInt()" @@ -47,7 +47,8 @@ <!-- Events --> <div v-for="id in idToPos.keys()" :key="id" class="absolute animate-fadein z-20" :style="eventStyles(id)"> <!-- Image --> - <div class="rounded-full" :style="eventImgStyles(id)"></div> + <div class="rounded-full cursor-pointer hover:brightness-125" :style="eventImgStyles(id)" + @click="onEventClick(id)"></div> <!-- Label --> <div class="text-center text-stone-100 text-sm whitespace-nowrap text-ellipsis overflow-hidden"> {{idToEvent.get(id)!.title}} @@ -60,7 +61,7 @@ <!-- Buttons --> <icon-button v-if="closeable" :size="30" class="absolute top-2 right-2 z-20" :style="{color: store.color.text, backgroundColor: store.color.altDark2}" - @click="emit('remove')" title="Remove timeline"> + @click="emit('close')" title="Close timeline"> <close-icon/> </icon-button> </div> @@ -76,7 +77,7 @@ import CloseIcon from './icon/CloseIcon.vue'; import {WRITING_MODE_HORZ, MIN_DATE, MAX_DATE, MONTH_SCALE, DAY_SCALE, SCALES, MONTH_NAMES, MIN_CAL_DATE, getDaysInMonth, HistDate, stepDate, getScaleRatio, getNumSubUnits, getUnitDiff, getEventPrecision, dateToUnit, - moduloPositive, TimelineState, HistEvent, getImagePath} from '../lib'; + moduloPositive, TimelineState, HistEvent} from '../lib'; import {useStore} from '../store'; import {RBTree} from '../rbtree'; @@ -95,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(['remove', 'state-chg', 'event-req', 'event-display']); +const emit = defineEmits(['close', 'state-chg', 'event-display', 'event-click']); // For size tracking const width = ref(0); @@ -1108,6 +1109,11 @@ 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/DownIcon.vue b/src/components/icon/DownIcon.vue new file mode 100644 index 0000000..f7a5835 --- /dev/null +++ b/src/components/icon/DownIcon.vue @@ -0,0 +1,6 @@ +<template> +<svg viewBox="0 0 24 24" fill="none" + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <polyline points="6 9 12 15 18 9"></polyline> +</svg> +</template> diff --git a/src/components/icon/ExternalLinkIcon.vue b/src/components/icon/ExternalLinkIcon.vue new file mode 100644 index 0000000..f672f3a --- /dev/null +++ b/src/components/icon/ExternalLinkIcon.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"> + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path> + <polyline points="15 3 21 3 21 9"></polyline> + <line x1="10" y1="14" x2="21" y2="3"></line> +</svg> +</template> @@ -267,6 +267,28 @@ export class HistEvent { this.pop = pop; } } +export class ImgInfo { + url: string; + license: string; + artist: string; + credit: string; + constructor(url: string, license: string, artist: string, credit: string){ + this.url = url; + this.license = license; + this.artist = artist; + this.credit = credit; + } +} +export class EventInfo { + desc: string; + wikiId: number; + imgInfo: ImgInfo; + constructor(desc: string, wikiId: number, imgInfo: ImgInfo){ + this.desc = desc; + this.wikiId = wikiId; + this.imgInfo = imgInfo; + } +} export function cmpHistEvent(event: HistEvent, event2: HistEvent){ const cmp = event.start.cmp(event2.start); return cmp != 0 ? cmp : event.id - event2.id; @@ -288,6 +310,9 @@ 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 { @@ -315,7 +340,18 @@ export type EventResponseJson = { events: HistEventJson[], unitCounts: {[x: number]: number} | null, } -export function jsonToHistDate(json: HistDateJson): HistDate{ +export type EventInfoJson = { + desc: string, + wikiId: number, + imgInfo: ImgInfoJson, +} +export type ImgInfoJson = { + url: string, + license: string, + artist: string, + credit: string, +} +export function jsonToHistDate(json: HistDateJson): HistDate { return new HistDate(json.gcal, json.year, json.month, json.day); } export function jsonToHistEvent(json: HistEventJson): HistEvent { @@ -331,6 +367,12 @@ export function jsonToHistEvent(json: HistEventJson): HistEvent { json.pop, ); } +export function jsonToEventInfo(json: EventInfoJson): EventInfo { + return new EventInfo(json.desc, json.wikiId, jsonToImgInfo(json.imgInfo)); +} +export function jsonToImgInfo(json: ImgInfoJson): ImgInfo { + return new ImgInfo(json.url, json.license, json.artist, json.credit); +} // For dates in a timeline const currentDate = new Date(); diff --git a/src/store.ts b/src/store.ts index bb29dba..1d51cc7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -20,6 +20,7 @@ export const useStore = defineStore('store', { altDark2: '#ca8a04', // yellow-600 altBg: '#6a5e2e', alt2: '#2563eb', // sky-600 + bgAlt: '#f5f5f4', // stone-100 }; return { tickLen: 16, @@ -44,6 +45,7 @@ export const useStore = defineStore('store', { color, showEventCounts: true, transitionDuration: 300, + borderRadius: 5, // px }; }, }); |
