mirror of
https://github.com/pds-nest/nest.git
synced 2024-11-22 13:04:19 +00:00
Merge remote-tracking branch 'origin/main' into main
This commit is contained in:
commit
237417298d
29 changed files with 956 additions and 319 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,7 @@
|
||||||
# Flask config
|
# Flask config
|
||||||
/config.py
|
/config.py
|
||||||
/.config.py
|
/.config.py
|
||||||
|
/nest_backend/config.py
|
||||||
|
|
||||||
# --- --- --- --- --- --- --- ---
|
# --- --- --- --- --- --- --- ---
|
||||||
# AUTOGENERATED STUFF BELOW
|
# AUTOGENERATED STUFF BELOW
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<option name="PARENT_ENVS" value="true" />
|
<option name="PARENT_ENVS" value="true" />
|
||||||
<envs>
|
<envs>
|
||||||
<env name="PYTHONUNBUFFERED" value="1" />
|
<env name="PYTHONUNBUFFERED" value="1" />
|
||||||
<env name="FLASK_CONFIG" value="../config.py" />
|
<env name="FLASK_CONFIG" value="config.py" />
|
||||||
</envs>
|
</envs>
|
||||||
<option name="SDK_HOME" value="$USER_HOME$/.cache/pypoetry/virtualenvs/nest--u3GVeLy-py3.9/bin/python" />
|
<option name="SDK_HOME" value="$USER_HOME$/.cache/pypoetry/virtualenvs/nest--u3GVeLy-py3.9/bin/python" />
|
||||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||||
|
|
|
@ -80,6 +80,25 @@ export default {
|
||||||
type: "Tipo",
|
type: "Tipo",
|
||||||
admin: "Amministratore",
|
admin: "Amministratore",
|
||||||
user: "Utente",
|
user: "Utente",
|
||||||
|
|
||||||
|
repoDeleted: "Questa repository è stata eliminata.",
|
||||||
|
hourlyGraph: "Grafico orario",
|
||||||
|
visualMap: "Mappa",
|
||||||
|
tweets: "Tweet",
|
||||||
|
stats: "Statistiche",
|
||||||
|
totTweets: "Tweet totali",
|
||||||
|
dispTweets: "Tweet mostrati",
|
||||||
|
dispTweetsPerc: "% di tweet mostrati",
|
||||||
|
locTweets: "Tweet con posizione",
|
||||||
|
locTweetsPerc: "% di tweet con posizione",
|
||||||
|
contTweets: "Tweet con contenuto",
|
||||||
|
contTweetsPerc: "% di tweet con contenuto",
|
||||||
|
wordCount: "Totale parole",
|
||||||
|
wordPop: "Parola più utilizzata",
|
||||||
|
imgTweets: "Tweet con immagine",
|
||||||
|
imgTweetsPerc: "% di tweet con immagine",
|
||||||
|
postUniq: "Totale utenti che hanno postato",
|
||||||
|
postPop: "Utente più attivo",
|
||||||
},
|
},
|
||||||
// 🇬🇧
|
// 🇬🇧
|
||||||
en: {
|
en: {
|
||||||
|
@ -151,6 +170,25 @@ export default {
|
||||||
type: "Type",
|
type: "Type",
|
||||||
admin: "Admin",
|
admin: "Admin",
|
||||||
user: "User",
|
user: "User",
|
||||||
|
|
||||||
|
repoDeleted: "This repository was deleted.",
|
||||||
|
hourlyGraph: "Hourly graph",
|
||||||
|
visualMap: "Map",
|
||||||
|
tweets: "Tweets",
|
||||||
|
stats: "Stats",
|
||||||
|
totTweets: "Total tweets",
|
||||||
|
dispTweets: "Displayed tweets",
|
||||||
|
dispTweetsPerc: "% of displayed tweets",
|
||||||
|
locTweets: "Tweets with location",
|
||||||
|
locTweetsPerc: "% of tweets with location",
|
||||||
|
contTweets: "Tweets with content",
|
||||||
|
contTweetsPerc: "% of tweets with content",
|
||||||
|
wordCount: "Word count",
|
||||||
|
wordPop: "Most popular word",
|
||||||
|
imgTweets: "Tweets with image",
|
||||||
|
imgTweetsPerc: "% of tweets with image",
|
||||||
|
postUniq: "Unique posters",
|
||||||
|
postPop: "Most active poster",
|
||||||
},
|
},
|
||||||
// 🇫🇮
|
// 🇫🇮
|
||||||
fi: {
|
fi: {
|
||||||
|
@ -222,5 +260,24 @@ export default {
|
||||||
type: "Tyyppi",
|
type: "Tyyppi",
|
||||||
admin: "Ylläpitäjä",
|
admin: "Ylläpitäjä",
|
||||||
user: "Käyttäjä",
|
user: "Käyttäjä",
|
||||||
|
|
||||||
|
repoDeleted: "Tämä arkisto on poistettu.",
|
||||||
|
tweets: "Twiitit",
|
||||||
|
hourlyGraph: "Tuntikohtainen kaavio",
|
||||||
|
visualMap: "Kartta",
|
||||||
|
stats: "Tilastot",
|
||||||
|
totTweets: "Twiitit yhteensä",
|
||||||
|
dispTweets: "Näytetyt twiitit",
|
||||||
|
dispTweetsPerc: "% näytetyistä twiiteistä",
|
||||||
|
locTweets: "Twiitit, joissa on sijainti",
|
||||||
|
locTweetsPerc: "% twiiteistä, joissa on sijainti",
|
||||||
|
contTweets: "Sisältöä sisältävät twiitit",
|
||||||
|
contTweetsPerc: "% sisältöä sisältävistä twiiteistä",
|
||||||
|
wordCount: "Sanojen määrä",
|
||||||
|
wordPop: "Suosituin sana",
|
||||||
|
imgTweets: "Twiitit, joissa on kuva",
|
||||||
|
imgTweetsPerc: "% twiiteistä, joissa on kuva",
|
||||||
|
postUniq: "Ainutkertaiset käyttäjät",
|
||||||
|
postPop: "Aktiivisimmat käyttäjät",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,17 @@
|
||||||
import React, { useRef } from "react"
|
import React from "react"
|
||||||
import BoxFull from "./BoxFull"
|
import BoxFull from "./BoxFull"
|
||||||
import ChartComponent from "react-chartjs-2"
|
import ChartComponent from "react-chartjs-2"
|
||||||
|
|
||||||
|
|
||||||
export default function BoxChart({chartProps, ...props}) {
|
export default function BoxChart({chartProps, ...props}) {
|
||||||
const boxContentsRef = useRef(null)
|
|
||||||
const getCssVar = (variable) => {
|
const getCssVar = (variable) => {
|
||||||
const computedStyle = window.getComputedStyle(boxContentsRef.current)
|
const computedStyle = window.getComputedStyle(document.querySelector("main"))
|
||||||
console.debug(variable, computedStyle.getPropertyValue(variable))
|
|
||||||
return computedStyle.getPropertyValue(variable).trim()
|
return computedStyle.getPropertyValue(variable).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoxFull
|
<BoxFull {...props}>
|
||||||
childrenProps={{ref: boxContentsRef}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{boxContentsRef.current ?
|
|
||||||
<ChartComponent
|
<ChartComponent
|
||||||
width={boxContentsRef.current.offsetWidth}
|
|
||||||
height={boxContentsRef.current.offsetHeight}
|
|
||||||
options={{
|
options={{
|
||||||
responsive: true,
|
responsive: true,
|
||||||
scales: {
|
scales: {
|
||||||
|
@ -51,10 +43,14 @@ export default function BoxChart({chartProps, ...props}) {
|
||||||
color: getCssVar("--fg-primary"),
|
color: getCssVar("--fg-primary"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
{...chartProps}
|
{...chartProps}
|
||||||
/>
|
/>
|
||||||
: null}
|
|
||||||
</BoxFull>
|
</BoxFull>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,21 @@
|
||||||
import React from "react"
|
import React, { useMemo } from "react"
|
||||||
import BoxFull from "../base/BoxFull"
|
import BoxFull from "../base/BoxFull"
|
||||||
import ReactWordcloud from "@steffo/nest-react-wordcloud"
|
import ReactWordcloud from "@steffo/nest-react-wordcloud"
|
||||||
import Style from "./BoxWordcloud.module.css"
|
import Style from "./BoxWordcloud.module.css"
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Box which displays a wordcloud.
|
* A {@link BoxFull} which displays a wordcloud.
|
||||||
*
|
*
|
||||||
* @param words - A list of word objects, made of a string "text" and a number "value"
|
* @param words - A list of word objects, made of a string "text" and a number "value"
|
||||||
|
* @param options - Additional options to pass to {@link ReactWordcloud}.
|
||||||
* @param props - Additional props to pass to the box.
|
* @param props - Additional props to pass to the box.
|
||||||
* @returns {JSX.Element}
|
* @returns {JSX.Element}
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export default function BoxWordcloud({ words, ...props }) {
|
export default function BoxWordcloud({ words, callbacks = {}, ...props }) {
|
||||||
return (
|
const wordcloud = useMemo(
|
||||||
<BoxFull {...props}>
|
() => (
|
||||||
<div className={Style.WordcloudContainer}>
|
|
||||||
<ReactWordcloud
|
<ReactWordcloud
|
||||||
options={{
|
options={{
|
||||||
colors: [
|
colors: [
|
||||||
|
@ -25,9 +25,22 @@ export default function BoxWordcloud({ words, ...props }) {
|
||||||
fontSizes: [8, 64],
|
fontSizes: [8, 64],
|
||||||
size: undefined,
|
size: undefined,
|
||||||
deterministic: true,
|
deterministic: true,
|
||||||
|
rotations: 0,
|
||||||
|
rotationAngles: [0, 0],
|
||||||
|
enableOptimizations: true,
|
||||||
|
enableTooltip: false,
|
||||||
}}
|
}}
|
||||||
words={words}
|
words={words}
|
||||||
|
callbacks={callbacks}
|
||||||
/>
|
/>
|
||||||
|
),
|
||||||
|
[words]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoxFull {...props}>
|
||||||
|
<div className={Style.WordcloudContainer}>
|
||||||
|
{wordcloud}
|
||||||
</div>
|
</div>
|
||||||
</BoxFull>
|
</BoxFull>
|
||||||
)
|
)
|
||||||
|
|
27
nest_frontend/components/interactive/BadgeFilter.js
Normal file
27
nest_frontend/components/interactive/BadgeFilter.js
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import React, { useContext } from "react"
|
||||||
|
import { faAt, faClock, faGlobe, faHashtag, faMapPin } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import ContextRepositoryEditor from "../../contexts/ContextRepositoryEditor"
|
||||||
|
import Badge from "../base/Badge"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link Badge} representing a Filter.
|
||||||
|
*
|
||||||
|
* @param filter - The Filter that this badge represents.
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export default function BadgeFilter({ filter }) {
|
||||||
|
const {removeFilter} = useContext(ContextRepositoryViewer)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={filter.color()}
|
||||||
|
icon={filter.icon()}
|
||||||
|
onClickDelete={() => removeFilter(filter)}
|
||||||
|
>
|
||||||
|
{filter.text()}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
28
nest_frontend/components/interactive/BoxFilterContains.js
Normal file
28
nest_frontend/components/interactive/BoxFilterContains.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import React, { useContext, useState } from "react"
|
||||||
|
import BoxFull from "../base/BoxFull"
|
||||||
|
import useRepositoryViewer from "../../hooks/useRepositoryViewer"
|
||||||
|
import useStrings from "../../hooks/useStrings"
|
||||||
|
import { ContainsFilter } from "../../utils/Filter"
|
||||||
|
import FormInlineText from "./FormInlineText"
|
||||||
|
import { faFont } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
|
||||||
|
|
||||||
|
export default function BoxFilterContains({ ...props }) {
|
||||||
|
const strings = useStrings()
|
||||||
|
const { appendFilter } = useRepositoryViewer()
|
||||||
|
|
||||||
|
const submit = value => {
|
||||||
|
appendFilter(new ContainsFilter(false, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: add this string
|
||||||
|
return (
|
||||||
|
<BoxFull header={strings.filterContains} {...props}>
|
||||||
|
<FormInlineText
|
||||||
|
textIcon={faFont}
|
||||||
|
submit={submit}
|
||||||
|
placeholder={"cat in the box"}
|
||||||
|
/>
|
||||||
|
</BoxFull>
|
||||||
|
)
|
||||||
|
}
|
43
nest_frontend/components/interactive/BoxFilterHashtag.js
Normal file
43
nest_frontend/components/interactive/BoxFilterHashtag.js
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import React, { useContext, useState } from "react"
|
||||||
|
import BoxFull from "../base/BoxFull"
|
||||||
|
import FormInline from "../base/FormInline"
|
||||||
|
import InputWithIcon from "../base/InputWithIcon"
|
||||||
|
import Style from "./BoxConditionUser.module.css"
|
||||||
|
import { faAt, faFilter, faFont, faHashtag } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import ButtonIconOnly from "../base/ButtonIconOnly"
|
||||||
|
import useRepositoryViewer from "../../hooks/useRepositoryViewer"
|
||||||
|
import useStrings from "../../hooks/useStrings"
|
||||||
|
import { ContainsFilter, HashtagFilter, UserFilter } from "../../utils/Filter"
|
||||||
|
import Condition from "../../utils/Condition"
|
||||||
|
import FormInlineText from "./FormInlineText"
|
||||||
|
|
||||||
|
|
||||||
|
const INVALID_HASHTAG_CHARACTERS = /([^a-z0-9_\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff\u0100-\u024f\u0253-\u0254\u0256-\u0257\u0300-\u036f\u1e00-\u1eff\u0400-\u04ff\u0500-\u0527\u2de0-\u2dff\ua640-\ua69f\u0591-\u05bf\u05c1-\u05c2\u05c4-\u05c5\u05d0-\u05ea\u05f0-\u05f4\ufb12-\ufb28\ufb2a-\ufb36\ufb38-\ufb3c\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufb4f\u0610-\u061a\u0620-\u065f\u066e-\u06d3\u06d5-\u06dc\u06de-\u06e8\u06ea-\u06ef\u06fa-\u06fc\u0750-\u077f\u08a2-\u08ac\u08e4-\u08fe\ufb50-\ufbb1\ufbd3-\ufd3d\ufd50-\ufd8f\ufd92-\ufdc7\ufdf0-\ufdfb\ufe70-\ufe74\ufe76-\ufefc\u200c\u0e01-\u0e3a\u0e40-\u0e4e\u1100-\u11ff\u3130-\u3185\ua960-\ua97f\uac00-\ud7af\ud7b0-\ud7ff\uffa1-\uffdc\u30a1-\u30fa\u30fc-\u30fe\uff66-\uff9f\uff10-\uff19\uff21-\uff3a\uff41-\uff5a\u3041-\u3096\u3099-\u309e\u3400-\u4dbf\u4e00-\u9fff\u20000-\u2a6df\u2a700-\u2b73\u2b740-\u2b81\u2f800-\u2fa1])/g
|
||||||
|
|
||||||
|
|
||||||
|
export default function BoxFilterHashtag({ ...props }) {
|
||||||
|
// TODO: Translate this
|
||||||
|
// TODO: and also use a better string maybe
|
||||||
|
const strings = useStrings()
|
||||||
|
|
||||||
|
const { appendFilter } = useRepositoryViewer()
|
||||||
|
|
||||||
|
const validate = value => {
|
||||||
|
return value.replace(INVALID_HASHTAG_CHARACTERS, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = value => {
|
||||||
|
appendFilter(new HashtagFilter(false, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoxFull header={strings.filterHashtag} {...props}>
|
||||||
|
<FormInlineText
|
||||||
|
textIcon={faHashtag}
|
||||||
|
placeholder={"hashtag"}
|
||||||
|
validate={validate}
|
||||||
|
submit={submit}
|
||||||
|
/>
|
||||||
|
</BoxFull>
|
||||||
|
)
|
||||||
|
}
|
40
nest_frontend/components/interactive/BoxFilterUser.js
Normal file
40
nest_frontend/components/interactive/BoxFilterUser.js
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { useContext, useState } from "react"
|
||||||
|
import BoxFull from "../base/BoxFull"
|
||||||
|
import FormInline from "../base/FormInline"
|
||||||
|
import InputWithIcon from "../base/InputWithIcon"
|
||||||
|
import Style from "./BoxConditionUser.module.css"
|
||||||
|
import { faAt, faFilter, faFont } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import ButtonIconOnly from "../base/ButtonIconOnly"
|
||||||
|
import useRepositoryViewer from "../../hooks/useRepositoryViewer"
|
||||||
|
import useStrings from "../../hooks/useStrings"
|
||||||
|
import { ContainsFilter, UserFilter } from "../../utils/Filter"
|
||||||
|
import Condition from "../../utils/Condition"
|
||||||
|
import FormInlineText from "./FormInlineText"
|
||||||
|
|
||||||
|
|
||||||
|
export default function BoxFilterUser({ ...props }) {
|
||||||
|
// TODO: Translate this
|
||||||
|
// TODO: and also use a better string maybe
|
||||||
|
const strings = useStrings()
|
||||||
|
|
||||||
|
const { appendFilter } = useRepositoryViewer()
|
||||||
|
|
||||||
|
const validate = value => {
|
||||||
|
return value.replace(/[^a-zA-Z0-9]/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = value => {
|
||||||
|
appendFilter(new UserFilter(false, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoxFull header={strings.filterUser} {...props}>
|
||||||
|
<FormInlineText
|
||||||
|
textIcon={faAt}
|
||||||
|
placeholder={"jack"}
|
||||||
|
validate={validate}
|
||||||
|
submit={submit}
|
||||||
|
/>
|
||||||
|
</BoxFull>
|
||||||
|
)
|
||||||
|
}
|
27
nest_frontend/components/interactive/BoxFilters.js
Normal file
27
nest_frontend/components/interactive/BoxFilters.js
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import React, { useContext } from "react"
|
||||||
|
import BoxFull from "../base/BoxFull"
|
||||||
|
import ContextLanguage from "../../contexts/ContextLanguage"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
import BadgeFilter from "./BadgeFilter"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A box which renders all filters of the {@link ContextRepositoryViewer} as {@link BadgeFilter}s.
|
||||||
|
*
|
||||||
|
* @param props
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export default function BoxFilters({ ...props }) {
|
||||||
|
const { strings } = useContext(ContextLanguage)
|
||||||
|
const {filters} = useContext(ContextRepositoryViewer)
|
||||||
|
|
||||||
|
const badges = filters.map((filter, pos) => <BadgeFilter key={pos} filter={filter}/>)
|
||||||
|
|
||||||
|
// TODO: localize this
|
||||||
|
return (
|
||||||
|
<BoxFull header={"Filters"} {...props}>
|
||||||
|
{badges}
|
||||||
|
</BoxFull>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,11 +1,14 @@
|
||||||
import React from "react"
|
import React, { useContext } from "react"
|
||||||
import BoxFullScrollable from "../base/BoxFullScrollable"
|
import BoxFullScrollable from "../base/BoxFullScrollable"
|
||||||
import SummaryTweet from "./SummaryTweet"
|
import SummaryTweet from "./SummaryTweet"
|
||||||
|
import ContextLanguage from "../../contexts/ContextLanguage"
|
||||||
import Empty from "./Empty"
|
import Empty from "./Empty"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
|
||||||
|
|
||||||
export default function BoxRepositoryTweets({ tweets, ...props }) {
|
export default function BoxRepositoryTweets({ ...props }) {
|
||||||
// TODO: Translate this
|
const { strings } = useContext(ContextLanguage)
|
||||||
|
const {tweets} = useContext(ContextRepositoryViewer)
|
||||||
|
|
||||||
let content
|
let content
|
||||||
if(tweets.length === 0) {
|
if(tweets.length === 0) {
|
||||||
|
@ -16,7 +19,7 @@ export default function BoxRepositoryTweets({ tweets, ...props }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoxFullScrollable header={"Tweets"} {...props}>
|
<BoxFullScrollable header={strings.tweets} {...props}>
|
||||||
{content}
|
{content}
|
||||||
</BoxFullScrollable>
|
</BoxFullScrollable>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,23 +1,34 @@
|
||||||
import React from "react"
|
import React, { useContext } from "react"
|
||||||
import BoxFull from "../base/BoxFull"
|
import BoxFull from "../base/BoxFull"
|
||||||
import BoxChart from "../base/BoxChart"
|
import BoxChart from "../base/BoxChart"
|
||||||
|
import Empty from "./Empty"
|
||||||
|
import ContextLanguage from "../../contexts/ContextLanguage"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
|
||||||
|
|
||||||
export default function BoxVisualizationChart({ tweets, ...props }) {
|
export default function BoxVisualizationChart({ ...props }) {
|
||||||
// TODO: translate this
|
const { strings } = useContext(ContextLanguage)
|
||||||
|
const {tweets} = useContext(ContextRepositoryViewer)
|
||||||
|
|
||||||
const hours = [...Array(24).keys()].map(hour => hour.toString())
|
const hours = [...Array(24).keys()].map(hour => hour.toString())
|
||||||
const hourlyTweetCount = Array(24).fill(0)
|
const hourlyTweetCount = Array(24).fill(0)
|
||||||
for(const tweet of tweets) {
|
for(const tweet of tweets) {
|
||||||
const insertDate = new Date(tweet["insert_time"])
|
const insertDate = new Date(tweet["insert_time"])
|
||||||
const insertHour = insertDate.getHours()
|
const insertHour = insertDate.getHours()
|
||||||
console.log(insertHour)
|
|
||||||
hourlyTweetCount[insertHour] += 1
|
hourlyTweetCount[insertHour] += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(tweets.length === 0) {
|
||||||
|
return (
|
||||||
|
<BoxFull header={"Hourly graph"} {...props}>
|
||||||
|
<Empty/>
|
||||||
|
</BoxFull>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoxChart
|
<BoxChart
|
||||||
header={"Hourly graph"}
|
header={strings.hourlyGraph}
|
||||||
chartProps={{
|
chartProps={{
|
||||||
type: "bar",
|
type: "bar",
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -2,24 +2,20 @@ import React, { useContext } from "react"
|
||||||
import BoxMap from "../base/BoxMap"
|
import BoxMap from "../base/BoxMap"
|
||||||
import ContextLanguage from "../../contexts/ContextLanguage"
|
import ContextLanguage from "../../contexts/ContextLanguage"
|
||||||
import { Marker, Popup } from "react-leaflet"
|
import { Marker, Popup } from "react-leaflet"
|
||||||
|
import { Location } from "../../utils/location"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
|
||||||
|
|
||||||
const locationRegex = /[{](?<lat>[0-9.]+),(?<lng>[0-9.]+)[}]/
|
export default function BoxVisualizationMap({ ...props }) {
|
||||||
|
|
||||||
export default function BoxVisualizationMap({ tweets, ...props }) {
|
|
||||||
// TODO: translate this
|
|
||||||
const { strings } = useContext(ContextLanguage)
|
const { strings } = useContext(ContextLanguage)
|
||||||
|
const {tweets} = useContext(ContextRepositoryViewer)
|
||||||
|
|
||||||
console.debug(tweets)
|
console.debug(tweets)
|
||||||
const markers = tweets.filter(tweet => tweet.location).map(tweet => {
|
const markers = tweets.filter(tweet => tweet.location).map(tweet => {
|
||||||
const match = locationRegex.exec(tweet.location)
|
const location = Location.fromTweet(tweet)
|
||||||
if(!match) {
|
|
||||||
console.error("No match for location ", tweet.location)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const { lat, lng } = match.groups
|
|
||||||
return (
|
return (
|
||||||
<Marker key={tweet["snowflake"]} position={[Number.parseFloat(lat), Number.parseFloat(lng)]}>
|
<Marker key={tweet["snowflake"]} position={location.toArray()}>
|
||||||
<Popup>
|
<Popup>
|
||||||
<p>
|
<p>
|
||||||
{tweet["content"]}
|
{tweet["content"]}
|
||||||
|
@ -33,7 +29,7 @@ export default function BoxVisualizationMap({ tweets, ...props }) {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoxMap header={"Map"} {...props}>
|
<BoxMap header={strings.visualMap} {...props}>
|
||||||
{markers}
|
{markers}
|
||||||
</BoxMap>
|
</BoxMap>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
import React, { useMemo } from "react"
|
import React, { useContext, useMemo } from "react"
|
||||||
import FormLabelled from "../base/FormLabelled"
|
import FormLabelled from "../base/FormLabelled"
|
||||||
import FormLabel from "../base/formparts/FormLabel"
|
import FormLabel from "../base/formparts/FormLabel"
|
||||||
|
import ContextLanguage from "../../contexts/ContextLanguage"
|
||||||
import BoxFullScrollable from "../base/BoxFullScrollable"
|
import BoxFullScrollable from "../base/BoxFullScrollable"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
|
||||||
|
|
||||||
export default function BoxVisualizationStats({ tweets, words, totalTweetCount, ...props }) {
|
export default function BoxVisualizationStats({ ...props }) {
|
||||||
|
const { strings } = useContext(ContextLanguage)
|
||||||
|
const {tweets, words, rawTweets} = useContext(ContextRepositoryViewer)
|
||||||
|
|
||||||
const tweetCount = useMemo(
|
const tweetCount = useMemo(
|
||||||
() => tweets.length,
|
() => tweets.length,
|
||||||
|
@ -12,8 +16,8 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount,
|
||||||
)
|
)
|
||||||
|
|
||||||
const tweetPct = useMemo(
|
const tweetPct = useMemo(
|
||||||
() => tweetCount / totalTweetCount * 100,
|
() => tweetCount / rawTweets.length * 100,
|
||||||
[tweetCount, totalTweetCount],
|
[tweetCount, rawTweets],
|
||||||
)
|
)
|
||||||
|
|
||||||
const tweetLocationCount = useMemo(
|
const tweetLocationCount = useMemo(
|
||||||
|
@ -42,12 +46,16 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount,
|
||||||
)
|
)
|
||||||
|
|
||||||
const wordCount = useMemo(
|
const wordCount = useMemo(
|
||||||
() => words.map(word => word.value).reduce((a, b) => a + b),
|
() => {
|
||||||
[words],
|
if(words.length === 0) return 0
|
||||||
|
return words.map(word => word.value).reduce((a, b) => a + b)
|
||||||
|
},
|
||||||
|
[words]
|
||||||
)
|
)
|
||||||
|
|
||||||
const mostPopularWord = useMemo(
|
const mostPopularWord = useMemo(
|
||||||
() => {
|
() => {
|
||||||
|
if(words.length === 0) return "❌"
|
||||||
return words.sort((wa, wb) => {
|
return words.sort((wa, wb) => {
|
||||||
if(wa.value > wb.value) {
|
if(wa.value > wb.value) {
|
||||||
return -1
|
return -1
|
||||||
|
@ -101,48 +109,47 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount,
|
||||||
|
|
||||||
// TODO: missing stats
|
// TODO: missing stats
|
||||||
|
|
||||||
// TODO: translate this
|
|
||||||
return (
|
return (
|
||||||
<BoxFullScrollable header={"Stats"} {...props}>
|
<BoxFullScrollable header={strings.stats} {...props}>
|
||||||
<FormLabelled>
|
<FormLabelled>
|
||||||
<FormLabel text={"Total tweets"}>
|
<FormLabel text={strings.totTweets}>
|
||||||
<b>{totalTweetCount}</b>
|
<b>{rawTweets.length}</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"Displayed tweets"}>
|
<FormLabel text={strings.dispTweets}>
|
||||||
<b>{tweetCount}</b>
|
<b>{tweetCount}</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"% of displayed tweets"}>
|
<FormLabel text={strings.dispTweetsPerc}>
|
||||||
<b>{tweetPct.toFixed(2)}%</b>
|
<b>{tweetPct.toFixed(2)}%</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"Tweets with location"}>
|
<FormLabel text={strings.locTweets}>
|
||||||
<b>{tweetLocationCount}</b>
|
<b>{tweetLocationCount}</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"% of tweets with location"}>
|
<FormLabel text={strings.locTweetsPerc}>
|
||||||
<b>{tweetLocationPct.toFixed(2)}%</b>
|
<b>{tweetLocationPct.toFixed(2)}%</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"Tweets with content"}>
|
<FormLabel text={strings.contTweets}>
|
||||||
<b>{tweetContentCount}</b>
|
<b>{tweetContentCount}</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"% of tweets with content"}>
|
<FormLabel text={strings.contTweetsPerc}>
|
||||||
<b>{tweetContentPct.toFixed(2)}%</b>
|
<b>{tweetContentPct.toFixed(2)}%</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"Word count"}>
|
<FormLabel text={strings.wordCount}>
|
||||||
<b>{wordCount}</b>
|
<b>{wordCount}</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"Most popular word"}>
|
<FormLabel text={strings.wordPop}>
|
||||||
<b>{mostPopularWord}</b>
|
<b>{mostPopularWord}</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"Tweets with image"}>
|
<FormLabel text={strings.imgTweets}>
|
||||||
<b>🚧</b>
|
<b>🚧</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"% of tweets with image"}>
|
<FormLabel text={strings.imgTweetsPerc}>
|
||||||
<b>🚧</b>
|
<b>🚧</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"Unique posters"}>
|
<FormLabel text={strings.postUniq}>
|
||||||
<b>{uniqueUsersCount}</b>
|
<b>{uniqueUsersCount}</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormLabel text={"Most active poster"}>
|
<FormLabel text={strings.postPop}>
|
||||||
<b>{mostActiveUser.user} ({mostActiveUser.count} tweets)</b>
|
<b>{mostActiveUser ? `${mostActiveUser.user} (${mostActiveUser.count} tweet${mostActiveUser.count === 1 ? "" : "s"})` : "❌"}</b>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormLabelled>
|
</FormLabelled>
|
||||||
</BoxFullScrollable>
|
</BoxFullScrollable>
|
||||||
|
|
|
@ -1,12 +1,34 @@
|
||||||
import React, { useContext } from "react"
|
import React, { useContext } from "react"
|
||||||
import BoxWordcloud from "../base/BoxWordcloud"
|
import BoxWordcloud from "../base/BoxWordcloud"
|
||||||
import ContextLanguage from "../../contexts/ContextLanguage"
|
import ContextLanguage from "../../contexts/ContextLanguage"
|
||||||
|
import BoxFull from "../base/BoxFull"
|
||||||
|
import Empty from "./Empty"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
import { ContainsFilter } from "../../utils/Filter"
|
||||||
|
|
||||||
|
|
||||||
export default function BoxVisualizationWordcloud({ words, ...props }) {
|
export default function BoxVisualizationWordcloud({ ...props }) {
|
||||||
const { strings } = useContext(ContextLanguage)
|
const { strings } = useContext(ContextLanguage)
|
||||||
|
const {words, appendFilter} = useContext(ContextRepositoryViewer)
|
||||||
|
|
||||||
|
if(words.length === 0) {
|
||||||
|
return (
|
||||||
|
<BoxFull header={strings.wordcloud} {...props}>
|
||||||
|
<Empty/>
|
||||||
|
</BoxFull>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWordClick = word => {
|
||||||
|
appendFilter(new ContainsFilter(false, word.text))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoxWordcloud header={strings.wordcloud} words={words} {...props}/>
|
<BoxWordcloud
|
||||||
|
header={strings.wordcloud}
|
||||||
|
words={words}
|
||||||
|
callbacks={{onWordClick}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
14
nest_frontend/components/interactive/ButtonPicker.js
Normal file
14
nest_frontend/components/interactive/ButtonPicker.js
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import React from "react"
|
||||||
|
import ButtonIconOnly from "../base/ButtonIconOnly"
|
||||||
|
|
||||||
|
|
||||||
|
export default function ButtonPicker({ setTab, currentTab, name, ...props }) {
|
||||||
|
return (
|
||||||
|
<ButtonIconOnly
|
||||||
|
onClick={() => setTab(name)}
|
||||||
|
disabled={currentTab === name}
|
||||||
|
color={"Grey"}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
39
nest_frontend/components/interactive/FormInlineText.js
Normal file
39
nest_frontend/components/interactive/FormInlineText.js
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import FormInline from "../base/FormInline"
|
||||||
|
import InputWithIcon from "../base/InputWithIcon"
|
||||||
|
import { faPlus } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import ButtonIconOnly from "../base/ButtonIconOnly"
|
||||||
|
import Style from "./FormInlineText.module.css"
|
||||||
|
|
||||||
|
|
||||||
|
export default function FormInlineText({ textIcon, placeholder, buttonIcon = faPlus, buttonColor = "Green", validate = value => value, submit, ...props }) {
|
||||||
|
const [value, setValue] = useState("")
|
||||||
|
|
||||||
|
const _onSubmit = event => {
|
||||||
|
event.preventDefault()
|
||||||
|
submit(value)
|
||||||
|
setValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const _onChange = event => {
|
||||||
|
setValue(validate(event.target.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormInline onSubmit={_onSubmit} {...props}>
|
||||||
|
<InputWithIcon
|
||||||
|
className={Style.Input}
|
||||||
|
icon={textIcon}
|
||||||
|
value={value}
|
||||||
|
onChange={_onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<ButtonIconOnly
|
||||||
|
className={Style.Button}
|
||||||
|
icon={buttonIcon}
|
||||||
|
color={buttonColor}
|
||||||
|
onClick={_onSubmit}
|
||||||
|
/>
|
||||||
|
</FormInline>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
.Input {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
|
||||||
|
}
|
|
@ -1,23 +1,44 @@
|
||||||
import React from "react"
|
import React, { useContext } from "react"
|
||||||
import ButtonIconOnly from "../base/ButtonIconOnly"
|
import ButtonIconOnly from "../base/ButtonIconOnly"
|
||||||
import { faAt, faClock, faHashtag, faMapPin } from "@fortawesome/free-solid-svg-icons"
|
import { faAt, faClock, faFont, faHashtag, faMapPin, faStar } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import ButtonPicker from "./ButtonPicker"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
|
||||||
|
|
||||||
export default function PickerFilter({ currentTab, setTab, ...props }) {
|
export default function PickerFilter({ ...props }) {
|
||||||
|
const {filterTab, setFilterTab} = useContext(ContextRepositoryViewer)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props}>
|
<div {...props}>
|
||||||
<ButtonIconOnly
|
<ButtonPicker
|
||||||
onClick={() => setTab("hashtag")} disabled={currentTab ===
|
currentTab={filterTab}
|
||||||
"hashtag"} color={"Grey"} icon={faHashtag}
|
setTab={setFilterTab}
|
||||||
|
name={"contains"}
|
||||||
|
icon={faFont}
|
||||||
/>
|
/>
|
||||||
<ButtonIconOnly onClick={() => setTab("user")} disabled={currentTab === "user"} color={"Grey"} icon={faAt}/>
|
<ButtonPicker
|
||||||
<ButtonIconOnly
|
currentTab={filterTab}
|
||||||
onClick={() => setTab("location")} disabled={currentTab ===
|
setTab={setFilterTab}
|
||||||
"location"} color={"Grey"} icon={faMapPin}
|
name={"hashtag"}
|
||||||
|
icon={faHashtag}
|
||||||
/>
|
/>
|
||||||
<ButtonIconOnly
|
<ButtonPicker
|
||||||
onClick={() => setTab("time")} disabled={currentTab ===
|
currentTab={filterTab}
|
||||||
"time"} color={"Grey"} icon={faClock}
|
setTab={setFilterTab}
|
||||||
|
name={"user"}
|
||||||
|
icon={faAt}
|
||||||
|
/>
|
||||||
|
<ButtonPicker
|
||||||
|
currentTab={filterTab}
|
||||||
|
setTab={setFilterTab}
|
||||||
|
name={"time"}
|
||||||
|
icon={faClock}
|
||||||
|
/>
|
||||||
|
<ButtonPicker
|
||||||
|
currentTab={filterTab}
|
||||||
|
setTab={setFilterTab}
|
||||||
|
name={"location"}
|
||||||
|
icon={faMapPin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,24 +1,38 @@
|
||||||
import React from "react"
|
import React, { useContext } from "react"
|
||||||
import ButtonIconOnly from "../base/ButtonIconOnly"
|
|
||||||
import { faChartBar, faCloud, faMap, faStar } from "@fortawesome/free-solid-svg-icons"
|
import { faChartBar, faCloud, faMap, faStar } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
import ButtonPicker from "./ButtonPicker"
|
||||||
|
|
||||||
|
|
||||||
export default function PickerVisualization({ currentTab, setTab, ...props }) {
|
export default function PickerVisualization({...props}) {
|
||||||
|
const {visualizationTab, setVisualizationTab} = useContext(ContextRepositoryViewer)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props}>
|
<div {...props}>
|
||||||
<ButtonIconOnly
|
<ButtonPicker
|
||||||
onClick={() => setTab("stats")} disabled={currentTab ===
|
currentTab={visualizationTab}
|
||||||
"stats"} color={"Grey"} icon={faStar}
|
setTab={setVisualizationTab}
|
||||||
|
name={"stats"}
|
||||||
|
icon={faStar}
|
||||||
/>
|
/>
|
||||||
<ButtonIconOnly
|
<ButtonPicker
|
||||||
onClick={() => setTab("wordcloud")} disabled={currentTab ===
|
currentTab={visualizationTab}
|
||||||
"wordcloud"} color={"Grey"} icon={faCloud}
|
setTab={setVisualizationTab}
|
||||||
|
name={"wordcloud"}
|
||||||
|
icon={faCloud}
|
||||||
/>
|
/>
|
||||||
<ButtonIconOnly
|
<ButtonPicker
|
||||||
onClick={() => setTab("histogram")} disabled={currentTab ===
|
currentTab={visualizationTab}
|
||||||
"histogram"} color={"Grey"} icon={faChartBar}
|
setTab={setVisualizationTab}
|
||||||
|
name={"chart"}
|
||||||
|
icon={faChartBar}
|
||||||
|
/>
|
||||||
|
<ButtonPicker
|
||||||
|
currentTab={visualizationTab}
|
||||||
|
setTab={setVisualizationTab}
|
||||||
|
name={"map"}
|
||||||
|
icon={faMap}
|
||||||
/>
|
/>
|
||||||
<ButtonIconOnly onClick={() => setTab("map")} disabled={currentTab === "map"} color={"Grey"} icon={faMap}/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
150
nest_frontend/components/providers/RepositoryViewer.js
Normal file
150
nest_frontend/components/providers/RepositoryViewer.js
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
import React, { useContext, useMemo, useState } from "react"
|
||||||
|
import Style from "./RepositoryViewer.module.css"
|
||||||
|
import classNames from "classnames"
|
||||||
|
import ContextLanguage from "../../contexts/ContextLanguage"
|
||||||
|
import useBackendResource from "../../hooks/useBackendResource"
|
||||||
|
import useBackendViewset from "../../hooks/useBackendViewset"
|
||||||
|
import objectToWordcloudFormat from "../../utils/objectToWordcloudFormat"
|
||||||
|
import countTweetWords from "../../utils/countTweetWords"
|
||||||
|
import BoxHeader from "../base/BoxHeader"
|
||||||
|
import Loading from "../base/Loading"
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||||
|
import { faFolder, faFolderOpen, faTrash } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import BoxRepositoryTweets from "../interactive/BoxRepositoryTweets"
|
||||||
|
import PickerVisualization from "../interactive/PickerVisualization"
|
||||||
|
import BoxVisualizationWordcloud from "../interactive/BoxVisualizationWordcloud"
|
||||||
|
import BoxVisualizationChart from "../interactive/BoxVisualizationChart"
|
||||||
|
import BoxVisualizationMap from "../interactive/BoxVisualizationMap"
|
||||||
|
import BoxVisualizationStats from "../interactive/BoxVisualizationStats"
|
||||||
|
import PickerFilter from "../interactive/PickerFilter"
|
||||||
|
import useArrayState from "../../hooks/useArrayState"
|
||||||
|
import BoxFilters from "../interactive/BoxFilters"
|
||||||
|
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||||
|
import BoxFilterContains from "../interactive/BoxFilterContains"
|
||||||
|
import BoxFilterUser from "../interactive/BoxFilterUser"
|
||||||
|
import BoxFilterHashtag from "../interactive/BoxFilterHashtag"
|
||||||
|
|
||||||
|
|
||||||
|
export default function RepositoryViewer({ id, className, ...props }) {
|
||||||
|
const { strings } = useContext(ContextLanguage)
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [visualizationTab, setVisualizationTab] = useState("stats")
|
||||||
|
const [filterTab, setFilterTab] = useState("contains")
|
||||||
|
const {
|
||||||
|
value: filters,
|
||||||
|
setValue: setFilters,
|
||||||
|
appendValue: appendFilter,
|
||||||
|
spliceValue: spliceFilter,
|
||||||
|
removeValue: removeFilter
|
||||||
|
} = useArrayState([])
|
||||||
|
|
||||||
|
|
||||||
|
// Repository
|
||||||
|
const repositoryBr = useBackendResource(
|
||||||
|
`/api/v1/repositories/${id}`,
|
||||||
|
{
|
||||||
|
retrieve: true,
|
||||||
|
edit: true,
|
||||||
|
destroy: true,
|
||||||
|
action: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const repository = repositoryBr.error ? null : repositoryBr.resource
|
||||||
|
|
||||||
|
|
||||||
|
// Tweets
|
||||||
|
const rawTweetsBv = useBackendViewset(
|
||||||
|
`/api/v1/repositories/${id}/tweets/`,
|
||||||
|
"snowflake",
|
||||||
|
{
|
||||||
|
list: true,
|
||||||
|
create: false,
|
||||||
|
retrieve: false,
|
||||||
|
edit: false,
|
||||||
|
destroy: false,
|
||||||
|
command: false,
|
||||||
|
action: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const rawTweets = rawTweetsBv.resources && rawTweetsBv.error ? [] : rawTweetsBv.resources
|
||||||
|
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
let tweets = rawTweets
|
||||||
|
for(const filter of filters) {
|
||||||
|
tweets = tweets.filter(tweet => filter.exec(tweet))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Words
|
||||||
|
const words = useMemo(
|
||||||
|
() => objectToWordcloudFormat(countTweetWords(tweets)),
|
||||||
|
[tweets],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
let contents
|
||||||
|
if(!repositoryBr.firstLoad || !rawTweetsBv.firstLoad) {
|
||||||
|
contents = <>
|
||||||
|
<BoxHeader className={Style.Header}>
|
||||||
|
<Loading/>
|
||||||
|
</BoxHeader>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
else if(repository === null) {
|
||||||
|
contents = <>
|
||||||
|
<BoxHeader className={Style.Header}>
|
||||||
|
<FontAwesomeIcon icon={faTrash}/> <i>{strings.repoDeleted}</i>
|
||||||
|
</BoxHeader>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
contents = <>
|
||||||
|
<BoxHeader className={Style.Header}>
|
||||||
|
<FontAwesomeIcon icon={repository.is_active ? faFolderOpen : faFolder}/> {repository.name}
|
||||||
|
</BoxHeader>
|
||||||
|
|
||||||
|
<BoxRepositoryTweets className={Style.Tweets}/>
|
||||||
|
<PickerVisualization className={Style.VisualizationPicker}/>
|
||||||
|
{visualizationTab === "wordcloud" ? <BoxVisualizationWordcloud className={Style.Visualization}/> : null}
|
||||||
|
{visualizationTab === "chart" ? <BoxVisualizationChart className={Style.Visualization}/> : null}
|
||||||
|
{visualizationTab === "map" ? <BoxVisualizationMap className={Style.Visualization}/> : null}
|
||||||
|
{visualizationTab === "stats" ? <BoxVisualizationStats className={Style.Visualization}/> : null}
|
||||||
|
|
||||||
|
<BoxFilters className={Style.Filters}/>
|
||||||
|
<PickerFilter className={Style.FilterPicker}/>
|
||||||
|
{filterTab === "contains" ? <BoxFilterContains className={Style.AddFilter}/> : null}
|
||||||
|
{filterTab === "hashtag" ? <BoxFilterHashtag className={Style.AddFilter}/> : null}
|
||||||
|
{filterTab === "user" ? <BoxFilterUser className={Style.AddFilter}/> : null}
|
||||||
|
{filterTab === "time" ? "Time" : null}
|
||||||
|
{filterTab === "location" ? "Location" : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextRepositoryViewer.Provider value={{
|
||||||
|
visualizationTab,
|
||||||
|
setVisualizationTab,
|
||||||
|
filterTab,
|
||||||
|
setFilterTab,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
appendFilter,
|
||||||
|
spliceFilter,
|
||||||
|
removeFilter,
|
||||||
|
repositoryBr,
|
||||||
|
repository,
|
||||||
|
rawTweetsBv,
|
||||||
|
rawTweets,
|
||||||
|
tweets,
|
||||||
|
words,
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<div className={classNames(Style.RepositoryViewer, className)} {...props}>
|
||||||
|
{contents}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ContextRepositoryViewer.Provider>
|
||||||
|
)
|
||||||
|
}
|
|
@ -2,15 +2,48 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"b c d"
|
"h h"
|
||||||
"b e e"
|
"a b"
|
||||||
"b f f"
|
"a c"
|
||||||
"b g g";
|
"d e"
|
||||||
grid-template-columns: 400px 1fr 1fr;
|
"d f";
|
||||||
grid-template-rows: auto auto 1fr auto;
|
|
||||||
|
|
||||||
grid-gap: 10px;
|
grid-gap: 10px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: auto auto 1fr auto auto;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Header {
|
||||||
|
grid-area: h;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Tweets {
|
||||||
|
grid-area: a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.VisualizationPicker {
|
||||||
|
grid-area: b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Visualization {
|
||||||
|
grid-area: c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Filters {
|
||||||
|
grid-area: d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FilterPicker {
|
||||||
|
grid-area: e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.AddFilter {
|
||||||
|
grid-area: f;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { createContext } from "react"
|
import { createContext } from "react"
|
||||||
|
import LocalizationStrings from "../LocalizationStrings"
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -6,9 +7,11 @@ import { createContext } from "react"
|
||||||
* - `lang`: a string corresponding to the ISO 639-1 code of the current language
|
* - `lang`: a string corresponding to the ISO 639-1 code of the current language
|
||||||
* - `setLang`: a function to change the current language
|
* - `setLang`: a function to change the current language
|
||||||
* - `strings`: an object containing all strings of the current language
|
* - `strings`: an object containing all strings of the current language
|
||||||
|
*
|
||||||
|
* Defaults to Italian.
|
||||||
*/
|
*/
|
||||||
export default createContext({
|
export default createContext({
|
||||||
lang: null,
|
lang: "it",
|
||||||
setLang: () => console.error("Trying to setLang while outside a ContextServer.Provider!"),
|
setLang: () => console.error("Trying to setLang while outside a ContextServer.Provider!"),
|
||||||
strings: null,
|
strings: LocalizationStrings.it,
|
||||||
})
|
})
|
||||||
|
|
15
nest_frontend/hooks/useRepositoryViewer.js
Normal file
15
nest_frontend/hooks/useRepositoryViewer.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { useContext } from "react"
|
||||||
|
import ContextRepositoryEditor from "../contexts/ContextRepositoryEditor"
|
||||||
|
import ContextRepositoryViewer from "../contexts/ContextRepositoryViewer"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to quickly use {@link ContextRepositoryEditor}.
|
||||||
|
*/
|
||||||
|
export default function useRepositoryViewer() {
|
||||||
|
const context = useContext(ContextRepositoryViewer)
|
||||||
|
if(!context) {
|
||||||
|
throw new Error("This component must be placed inside a RepositoryViewer.")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
10
nest_frontend/hooks/useStrings.js
Normal file
10
nest_frontend/hooks/useStrings.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { useContext } from "react"
|
||||||
|
import ContextLanguage from "../contexts/ContextLanguage"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to quickly use the strings of {@link ContextLanguage}.
|
||||||
|
*/
|
||||||
|
export default function useStrings() {
|
||||||
|
return useContext(ContextLanguage).strings
|
||||||
|
}
|
|
@ -1,143 +1,12 @@
|
||||||
import React, { useContext, useMemo, useState } from "react"
|
import React, { useContext, useMemo, useState } from "react"
|
||||||
import Style from "./PageRepository.module.css"
|
|
||||||
import classNames from "classnames"
|
|
||||||
import BoxRepositoryTweets from "../components/interactive/BoxRepositoryTweets"
|
|
||||||
import BoxHeader from "../components/base/BoxHeader"
|
|
||||||
import PickerVisualization from "../components/interactive/PickerVisualization"
|
|
||||||
import PickerFilter from "../components/interactive/PickerFilter"
|
|
||||||
import useBackendViewset from "../hooks/useBackendViewset"
|
|
||||||
import useBackendResource from "../hooks/useBackendResource"
|
|
||||||
import { faFolder, faFolderOpen, faTrash } from "@fortawesome/free-solid-svg-icons"
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
|
||||||
import { useParams } from "react-router"
|
import { useParams } from "react-router"
|
||||||
import Loading from "../components/base/Loading"
|
import RepositoryViewer from "../components/providers/RepositoryViewer"
|
||||||
import BoxVisualizationStats from "../components/interactive/BoxVisualizationStats"
|
|
||||||
import BoxVisualizationChart from "../components/interactive/BoxVisualizationChart"
|
|
||||||
import BoxVisualizationMap from "../components/interactive/BoxVisualizationMap"
|
|
||||||
import BoxVisualizationWordcloud from "../components/interactive/BoxVisualizationWordcloud"
|
|
||||||
import BoxFull from "../components/base/BoxFull"
|
|
||||||
import ContextLanguage from "../contexts/ContextLanguage"
|
|
||||||
import countTweetWords from "../utils/countTweetWords"
|
|
||||||
import objectToWordcloudFormat from "../utils/objectToWordcloudFormat"
|
|
||||||
|
|
||||||
|
|
||||||
export default function PageRepository({ className, ...props }) {
|
export default function PageRepository({ className, ...props }) {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { strings } = useContext(ContextLanguage)
|
|
||||||
|
|
||||||
const [visualizationTab, setVisualizationTab] = useState("stats")
|
|
||||||
const [addFilterTab, setAddFilterTab] = useState("hashtag")
|
|
||||||
|
|
||||||
const repositoryBr = useBackendResource(
|
|
||||||
`/api/v1/repositories/${id}`,
|
|
||||||
{
|
|
||||||
retrieve: true,
|
|
||||||
edit: true,
|
|
||||||
destroy: true,
|
|
||||||
action: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const repository = repositoryBr.error ? null : repositoryBr.resource
|
|
||||||
|
|
||||||
const tweetsBv = useBackendViewset(
|
|
||||||
`/api/v1/repositories/${id}/tweets/`,
|
|
||||||
"snowflake",
|
|
||||||
{
|
|
||||||
list: true,
|
|
||||||
create: false,
|
|
||||||
retrieve: false,
|
|
||||||
edit: false,
|
|
||||||
destroy: false,
|
|
||||||
command: false,
|
|
||||||
action: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const tweets = tweetsBv.resources && tweetsBv.error ? [] : tweetsBv.resources
|
|
||||||
|
|
||||||
const words = useMemo(
|
|
||||||
() => objectToWordcloudFormat(countTweetWords(tweets)),
|
|
||||||
[tweets],
|
|
||||||
)
|
|
||||||
|
|
||||||
let contents
|
|
||||||
if(!repositoryBr.firstLoad || !tweetsBv.firstLoad) {
|
|
||||||
contents = <>
|
|
||||||
<BoxHeader className={Style.Header}>
|
|
||||||
<Loading/>
|
|
||||||
</BoxHeader>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
else if(repository === null) {
|
|
||||||
// TODO: Translate this!
|
|
||||||
contents = <>
|
|
||||||
<BoxHeader className={Style.Header}>
|
|
||||||
<FontAwesomeIcon icon={faTrash}/> <i>This repository was deleted.</i>
|
|
||||||
</BoxHeader>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
contents = <>
|
|
||||||
<BoxHeader className={Style.Header}>
|
|
||||||
<FontAwesomeIcon icon={repository.is_active ? faFolderOpen : faFolder}/> {repository.name}
|
|
||||||
</BoxHeader>
|
|
||||||
|
|
||||||
<BoxRepositoryTweets
|
|
||||||
className={Style.Tweets}
|
|
||||||
tweets={tweets}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PickerVisualization
|
|
||||||
className={Style.VisualizationPicker}
|
|
||||||
currentTab={visualizationTab}
|
|
||||||
setTab={setVisualizationTab}
|
|
||||||
/>
|
|
||||||
{visualizationTab === "wordcloud" ?
|
|
||||||
<BoxVisualizationWordcloud
|
|
||||||
className={Style.Wordcloud}
|
|
||||||
tweets={tweets}
|
|
||||||
words={words}
|
|
||||||
/>
|
|
||||||
: null}
|
|
||||||
{visualizationTab === "histogram" ?
|
|
||||||
<BoxVisualizationChart
|
|
||||||
className={Style.Wordcloud}
|
|
||||||
tweets={tweets}
|
|
||||||
/>
|
|
||||||
: null}
|
|
||||||
{visualizationTab === "map" ?
|
|
||||||
<BoxVisualizationMap
|
|
||||||
className={Style.Wordcloud}
|
|
||||||
tweets={tweets}
|
|
||||||
/>
|
|
||||||
: null}
|
|
||||||
{visualizationTab === "stats" ?
|
|
||||||
<BoxVisualizationStats
|
|
||||||
className={Style.Wordcloud}
|
|
||||||
tweets={tweets}
|
|
||||||
words={words}
|
|
||||||
totalTweetCount={tweets.length}
|
|
||||||
/>
|
|
||||||
: null}
|
|
||||||
|
|
||||||
<PickerFilter
|
|
||||||
className={Style.FilterPicker}
|
|
||||||
currentTab={addFilterTab}
|
|
||||||
setTab={setAddFilterTab}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BoxFull header={strings.notImplemented} className={Style.Filters}>
|
|
||||||
{strings.notImplemented}
|
|
||||||
</BoxFull>
|
|
||||||
|
|
||||||
<BoxFull header={strings.notImplemented} className={Style.AddFilter}>
|
|
||||||
{strings.notImplemented}
|
|
||||||
</BoxFull>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(Style.PageRepository, className)} {...props}>
|
<RepositoryViewer id={id}/>
|
||||||
{contents}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
.PageRepository {
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-areas:
|
|
||||||
"h h"
|
|
||||||
"a b"
|
|
||||||
"a c"
|
|
||||||
"d e"
|
|
||||||
"d f";
|
|
||||||
|
|
||||||
grid-gap: 10px;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
grid-template-rows: auto auto 1fr auto auto;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Header {
|
|
||||||
grid-area: h;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Tweets {
|
|
||||||
grid-area: a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Wordcloud {
|
|
||||||
grid-area: c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Filters {
|
|
||||||
grid-area: d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.AddFilter {
|
|
||||||
grid-area: f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.FilterPicker {
|
|
||||||
grid-area: e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.VisualizationPicker {
|
|
||||||
grid-area: b;
|
|
||||||
}
|
|
199
nest_frontend/utils/Filter.js
Normal file
199
nest_frontend/utils/Filter.js
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
import {Location} from "./location"
|
||||||
|
import {
|
||||||
|
faAt,
|
||||||
|
faFilter,
|
||||||
|
faFont, faHashtag,
|
||||||
|
faLocationArrow,
|
||||||
|
faMap,
|
||||||
|
faMapMarkerAlt,
|
||||||
|
faMapPin,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons"
|
||||||
|
|
||||||
|
|
||||||
|
export class Filter {
|
||||||
|
negate
|
||||||
|
|
||||||
|
constructor(negate) {
|
||||||
|
this.negate = negate
|
||||||
|
}
|
||||||
|
|
||||||
|
check(tweet) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(tweet) {
|
||||||
|
return this.check(tweet) ^ this.negate
|
||||||
|
}
|
||||||
|
|
||||||
|
color() {
|
||||||
|
return "Grey"
|
||||||
|
}
|
||||||
|
|
||||||
|
icon() {
|
||||||
|
return faFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
text() {
|
||||||
|
return this.negate ? "False" : "True"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContainsFilter extends Filter {
|
||||||
|
word
|
||||||
|
|
||||||
|
constructor(negate, word) {
|
||||||
|
super(negate)
|
||||||
|
this.word = word.toLowerCase().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
check(tweet) {
|
||||||
|
return tweet.content?.toLowerCase().includes(this.word)
|
||||||
|
}
|
||||||
|
|
||||||
|
color() {
|
||||||
|
return "Grey"
|
||||||
|
}
|
||||||
|
|
||||||
|
icon() {
|
||||||
|
return faFont
|
||||||
|
}
|
||||||
|
|
||||||
|
text() {
|
||||||
|
return this.word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class HashtagFilter extends ContainsFilter {
|
||||||
|
hashtag
|
||||||
|
|
||||||
|
constructor(negate, hashtag) {
|
||||||
|
super(negate, `#${hashtag}`);
|
||||||
|
this.hashtag = hashtag
|
||||||
|
}
|
||||||
|
|
||||||
|
icon() {
|
||||||
|
return faHashtag
|
||||||
|
}
|
||||||
|
|
||||||
|
text() {
|
||||||
|
return this.hashtag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class UserFilter extends Filter {
|
||||||
|
user
|
||||||
|
|
||||||
|
constructor(negate, user) {
|
||||||
|
super(negate)
|
||||||
|
this.user = user.toLowerCase().trim().replace(/^@/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
check(tweet) {
|
||||||
|
return tweet.poster.toLowerCase() === this.user
|
||||||
|
}
|
||||||
|
|
||||||
|
color() {
|
||||||
|
return "Green"
|
||||||
|
}
|
||||||
|
|
||||||
|
icon() {
|
||||||
|
return faAt
|
||||||
|
}
|
||||||
|
|
||||||
|
text() {
|
||||||
|
return this.user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class HasLocationFilter extends Filter {
|
||||||
|
hasLocation
|
||||||
|
|
||||||
|
constructor(negate, hasLocation) {
|
||||||
|
super(negate)
|
||||||
|
this.hasLocation = hasLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
check(tweet) {
|
||||||
|
return (tweet["location"] !== null) === this.hasLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
color() {
|
||||||
|
return "Red"
|
||||||
|
}
|
||||||
|
|
||||||
|
icon() {
|
||||||
|
return faMapMarkerAlt
|
||||||
|
}
|
||||||
|
|
||||||
|
text() {
|
||||||
|
return this.hasLocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class HasPlaceFilter extends Filter {
|
||||||
|
hasPlace
|
||||||
|
|
||||||
|
constructor(negate, hasPlace) {
|
||||||
|
super(negate)
|
||||||
|
this.hasPlace = hasPlace
|
||||||
|
}
|
||||||
|
|
||||||
|
check(tweet) {
|
||||||
|
return (tweet["place"] !== null) === this.hasPlace
|
||||||
|
}
|
||||||
|
|
||||||
|
color() {
|
||||||
|
return "Red"
|
||||||
|
}
|
||||||
|
|
||||||
|
icon() {
|
||||||
|
return faLocationArrow
|
||||||
|
}
|
||||||
|
|
||||||
|
text() {
|
||||||
|
return this.hasPlace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class LocationRadiusFilter extends HasLocationFilter {
|
||||||
|
center
|
||||||
|
radius
|
||||||
|
|
||||||
|
constructor(negate, center, radius) {
|
||||||
|
super(negate, true);
|
||||||
|
this.center = center
|
||||||
|
this.radius = radius
|
||||||
|
}
|
||||||
|
|
||||||
|
check(tweet) {
|
||||||
|
if(!super.check(tweet)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: assuming the earth is flat
|
||||||
|
const location = Location.fromTweet(tweet)
|
||||||
|
const latDiff = Math.abs(location.lat - this.center.lat)
|
||||||
|
const lngDiff = Math.abs(location.lng - this.center.lng)
|
||||||
|
const squaredDistance = Math.pow(latDiff, 2) + Math.pow(lngDiff, 2)
|
||||||
|
const squaredRadius = Math.pow(radius, 2)
|
||||||
|
|
||||||
|
return squaredDistance < squaredRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
color() {
|
||||||
|
return "Red"
|
||||||
|
}
|
||||||
|
|
||||||
|
icon() {
|
||||||
|
return faMapPin
|
||||||
|
}
|
||||||
|
|
||||||
|
text() {
|
||||||
|
return `< ${this.radius} ${this.center.toString()}`
|
||||||
|
}
|
||||||
|
}
|
37
nest_frontend/utils/location.js
Normal file
37
nest_frontend/utils/location.js
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
export const locationRegex = /[{](?<lat>[0-9.]+),(?<lng>[0-9.]+)[}]/
|
||||||
|
|
||||||
|
|
||||||
|
export class Location {
|
||||||
|
lat
|
||||||
|
lng
|
||||||
|
|
||||||
|
constructor(lat, lng) {
|
||||||
|
this.lat = lat
|
||||||
|
this.lng = lng
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromString(locString) {
|
||||||
|
const match = locationRegex.exec(locString)
|
||||||
|
if(!match) {
|
||||||
|
throw new Error(`Invalid location string: ${locString}`)
|
||||||
|
}
|
||||||
|
const {lat, lng} = match.groups
|
||||||
|
return new Location(lat, lng)
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromTweet(tweet) {
|
||||||
|
if(tweet.location === null) {
|
||||||
|
throw new Error(`Tweet has no location: ${tweet}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Location.fromString(tweet.location)
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray() {
|
||||||
|
return [this.lat, this.lng]
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `${this.lat.toFixed(3)} ${this.lng.toFixed(3)}`
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue