1
Fork 0
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:
Lorenzo Balugani 2021-05-21 17:40:35 +02:00
commit 237417298d
29 changed files with 956 additions and 319 deletions

1
.gitignore vendored
View file

@ -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

View file

@ -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$" />

View file

@ -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",
}, },
} }

View file

@ -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>
) )
} }

View file

@ -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>
) )

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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>
) )

View file

@ -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: {

View file

@ -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>
) )

View file

@ -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>

View file

@ -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}
/>
) )
} }

View 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}
/>
)
}

View 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>
)
}

View file

@ -0,0 +1,7 @@
.Input {
flex-shrink: 1;
}
.Button {
}

View file

@ -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>
) )

View file

@ -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>
) )
} }

View 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>
)
}

View file

@ -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;
}

View file

@ -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,
}) })

View 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
}

View 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
}

View file

@ -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>
) )
} }

View file

@ -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;
}

View 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()}`
}
}

View 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)}`
}
}