diff --git a/.gitignore b/.gitignore index 88ed660..bdeff09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Flask config /config.py /.config.py +/nest_backend/config.py # --- --- --- --- --- --- --- --- # AUTOGENERATED STUFF BELOW diff --git a/.idea/runConfigurations/Backend.xml b/.idea/runConfigurations/Backend.xml index c3cdc20..805354d 100644 --- a/.idea/runConfigurations/Backend.xml +++ b/.idea/runConfigurations/Backend.xml @@ -5,7 +5,7 @@ - + diff --git a/nest_frontend/LocalizationStrings.js b/nest_frontend/LocalizationStrings.js index 481f91f..8b6b05c 100644 --- a/nest_frontend/LocalizationStrings.js +++ b/nest_frontend/LocalizationStrings.js @@ -80,6 +80,25 @@ export default { type: "Tipo", admin: "Amministratore", 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: { @@ -151,6 +170,25 @@ export default { type: "Type", admin: "Admin", 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: { @@ -222,5 +260,24 @@ export default { type: "Tyyppi", admin: "Ylläpitä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", }, } diff --git a/nest_frontend/components/base/BoxChart.js b/nest_frontend/components/base/BoxChart.js index ebb7c93..3e8bee7 100644 --- a/nest_frontend/components/base/BoxChart.js +++ b/nest_frontend/components/base/BoxChart.js @@ -1,60 +1,56 @@ -import React, { useRef } from "react" +import React from "react" import BoxFull from "./BoxFull" import ChartComponent from "react-chartjs-2" export default function BoxChart({chartProps, ...props}) { - const boxContentsRef = useRef(null) const getCssVar = (variable) => { - const computedStyle = window.getComputedStyle(boxContentsRef.current) - console.debug(variable, computedStyle.getPropertyValue(variable)) + const computedStyle = window.getComputedStyle(document.querySelector("main")) return computedStyle.getPropertyValue(variable).trim() } return ( - - {boxContentsRef.current ? - + - : null} + y: { + beginAtZero: true, + grid: { + borderColor: getCssVar("--bg-light"), + color: getCssVar("--bg-light"), + }, + ticks: { + color: getCssVar("--fg-primary"), + } + }, + }, + elements: { + bar: { + backgroundColor: getCssVar("--fg-primary"), + borderColor: "transparent", + color: getCssVar("--fg-primary"), + }, + }, + plugins: { + legend: { + display: false, + } + } + }} + {...chartProps} + /> ) } diff --git a/nest_frontend/components/base/BoxWordcloud.js b/nest_frontend/components/base/BoxWordcloud.js index a74cf3d..8bad4f1 100644 --- a/nest_frontend/components/base/BoxWordcloud.js +++ b/nest_frontend/components/base/BoxWordcloud.js @@ -1,33 +1,46 @@ -import React from "react" +import React, { useMemo } from "react" import BoxFull from "../base/BoxFull" import ReactWordcloud from "@steffo/nest-react-wordcloud" 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 options - Additional options to pass to {@link ReactWordcloud}. * @param props - Additional props to pass to the box. * @returns {JSX.Element} * @constructor */ -export default function BoxWordcloud({ words, ...props }) { +export default function BoxWordcloud({ words, callbacks = {}, ...props }) { + const wordcloud = useMemo( + () => ( + + ), + [words] + ) + return ( - + {wordcloud} ) diff --git a/nest_frontend/components/interactive/BadgeFilter.js b/nest_frontend/components/interactive/BadgeFilter.js new file mode 100644 index 0000000..d4123f7 --- /dev/null +++ b/nest_frontend/components/interactive/BadgeFilter.js @@ -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 ( + removeFilter(filter)} + > + {filter.text()} + + ) +} diff --git a/nest_frontend/components/interactive/BoxFilterContains.js b/nest_frontend/components/interactive/BoxFilterContains.js new file mode 100644 index 0000000..b36f4ee --- /dev/null +++ b/nest_frontend/components/interactive/BoxFilterContains.js @@ -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 ( + + + + ) +} diff --git a/nest_frontend/components/interactive/BoxFilterHashtag.js b/nest_frontend/components/interactive/BoxFilterHashtag.js new file mode 100644 index 0000000..4096492 --- /dev/null +++ b/nest_frontend/components/interactive/BoxFilterHashtag.js @@ -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 ( + + + + ) +} diff --git a/nest_frontend/components/interactive/BoxFilterUser.js b/nest_frontend/components/interactive/BoxFilterUser.js new file mode 100644 index 0000000..37d24ea --- /dev/null +++ b/nest_frontend/components/interactive/BoxFilterUser.js @@ -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 ( + + + + ) +} diff --git a/nest_frontend/components/interactive/BoxFilters.js b/nest_frontend/components/interactive/BoxFilters.js new file mode 100644 index 0000000..e9fe1f0 --- /dev/null +++ b/nest_frontend/components/interactive/BoxFilters.js @@ -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) => ) + + // TODO: localize this + return ( + + {badges} + + ) +} diff --git a/nest_frontend/components/interactive/BoxRepositoryTweets.js b/nest_frontend/components/interactive/BoxRepositoryTweets.js index 166f65a..48966a7 100644 --- a/nest_frontend/components/interactive/BoxRepositoryTweets.js +++ b/nest_frontend/components/interactive/BoxRepositoryTweets.js @@ -1,11 +1,14 @@ -import React from "react" +import React, { useContext } from "react" import BoxFullScrollable from "../base/BoxFullScrollable" import SummaryTweet from "./SummaryTweet" +import ContextLanguage from "../../contexts/ContextLanguage" import Empty from "./Empty" +import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer" -export default function BoxRepositoryTweets({ tweets, ...props }) { - // TODO: Translate this +export default function BoxRepositoryTweets({ ...props }) { + const { strings } = useContext(ContextLanguage) + const {tweets} = useContext(ContextRepositoryViewer) let content if(tweets.length === 0) { @@ -16,7 +19,7 @@ export default function BoxRepositoryTweets({ tweets, ...props }) { } return ( - + {content} ) diff --git a/nest_frontend/components/interactive/BoxVisualizationChart.js b/nest_frontend/components/interactive/BoxVisualizationChart.js index efda701..b5718e0 100644 --- a/nest_frontend/components/interactive/BoxVisualizationChart.js +++ b/nest_frontend/components/interactive/BoxVisualizationChart.js @@ -1,23 +1,34 @@ -import React from "react" +import React, { useContext } from "react" import BoxFull from "../base/BoxFull" 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 }) { - // TODO: translate this +export default function BoxVisualizationChart({ ...props }) { + const { strings } = useContext(ContextLanguage) + const {tweets} = useContext(ContextRepositoryViewer) + const hours = [...Array(24).keys()].map(hour => hour.toString()) const hourlyTweetCount = Array(24).fill(0) for(const tweet of tweets) { const insertDate = new Date(tweet["insert_time"]) const insertHour = insertDate.getHours() - console.log(insertHour) hourlyTweetCount[insertHour] += 1 } + if(tweets.length === 0) { + return ( + + + + ) + } return ( [0-9.]+),(?[0-9.]+)[}]/ - -export default function BoxVisualizationMap({ tweets, ...props }) { - // TODO: translate this +export default function BoxVisualizationMap({ ...props }) { const { strings } = useContext(ContextLanguage) + const {tweets} = useContext(ContextRepositoryViewer) console.debug(tweets) const markers = tweets.filter(tweet => tweet.location).map(tweet => { - const match = locationRegex.exec(tweet.location) - if(!match) { - console.error("No match for location ", tweet.location) - return null - } - const { lat, lng } = match.groups + const location = Location.fromTweet(tweet) + return ( - + {tweet["content"]} @@ -33,7 +29,7 @@ export default function BoxVisualizationMap({ tweets, ...props }) { }) return ( - + {markers} ) diff --git a/nest_frontend/components/interactive/BoxVisualizationStats.js b/nest_frontend/components/interactive/BoxVisualizationStats.js index df11a20..42825b6 100644 --- a/nest_frontend/components/interactive/BoxVisualizationStats.js +++ b/nest_frontend/components/interactive/BoxVisualizationStats.js @@ -1,10 +1,14 @@ -import React, { useMemo } from "react" +import React, { useContext, useMemo } from "react" import FormLabelled from "../base/FormLabelled" import FormLabel from "../base/formparts/FormLabel" +import ContextLanguage from "../../contexts/ContextLanguage" 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( () => tweets.length, @@ -12,8 +16,8 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount, ) const tweetPct = useMemo( - () => tweetCount / totalTweetCount * 100, - [tweetCount, totalTweetCount], + () => tweetCount / rawTweets.length * 100, + [tweetCount, rawTweets], ) const tweetLocationCount = useMemo( @@ -42,12 +46,16 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount, ) 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( () => { + if(words.length === 0) return "❌" return words.sort((wa, wb) => { if(wa.value > wb.value) { return -1 @@ -101,48 +109,47 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount, // TODO: missing stats - // TODO: translate this return ( - + - - {totalTweetCount} + + {rawTweets.length} - + {tweetCount} - + {tweetPct.toFixed(2)}% - + {tweetLocationCount} - + {tweetLocationPct.toFixed(2)}% - + {tweetContentCount} - + {tweetContentPct.toFixed(2)}% - + {wordCount} - + {mostPopularWord} - + 🚧 - + 🚧 - + {uniqueUsersCount} - - {mostActiveUser.user} ({mostActiveUser.count} tweets) + + {mostActiveUser ? `${mostActiveUser.user} (${mostActiveUser.count} tweet${mostActiveUser.count === 1 ? "" : "s"})` : "❌"} diff --git a/nest_frontend/components/interactive/BoxVisualizationWordcloud.js b/nest_frontend/components/interactive/BoxVisualizationWordcloud.js index 780e1a3..543274e 100644 --- a/nest_frontend/components/interactive/BoxVisualizationWordcloud.js +++ b/nest_frontend/components/interactive/BoxVisualizationWordcloud.js @@ -1,12 +1,34 @@ import React, { useContext } from "react" import BoxWordcloud from "../base/BoxWordcloud" 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 {words, appendFilter} = useContext(ContextRepositoryViewer) + + if(words.length === 0) { + return ( + + + + ) + } + + const onWordClick = word => { + appendFilter(new ContainsFilter(false, word.text)) + } return ( - + ) } diff --git a/nest_frontend/components/interactive/ButtonPicker.js b/nest_frontend/components/interactive/ButtonPicker.js new file mode 100644 index 0000000..43029ee --- /dev/null +++ b/nest_frontend/components/interactive/ButtonPicker.js @@ -0,0 +1,14 @@ +import React from "react" +import ButtonIconOnly from "../base/ButtonIconOnly" + + +export default function ButtonPicker({ setTab, currentTab, name, ...props }) { + return ( + setTab(name)} + disabled={currentTab === name} + color={"Grey"} + {...props} + /> + ) +} diff --git a/nest_frontend/components/interactive/FormInlineText.js b/nest_frontend/components/interactive/FormInlineText.js new file mode 100644 index 0000000..660e4aa --- /dev/null +++ b/nest_frontend/components/interactive/FormInlineText.js @@ -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 ( + + + + + ) +} diff --git a/nest_frontend/components/interactive/FormInlineText.module.css b/nest_frontend/components/interactive/FormInlineText.module.css new file mode 100644 index 0000000..f8b3933 --- /dev/null +++ b/nest_frontend/components/interactive/FormInlineText.module.css @@ -0,0 +1,7 @@ +.Input { + flex-shrink: 1; +} + +.Button { + +} \ No newline at end of file diff --git a/nest_frontend/components/interactive/PickerFilter.js b/nest_frontend/components/interactive/PickerFilter.js index ffc1b77..4416abf 100644 --- a/nest_frontend/components/interactive/PickerFilter.js +++ b/nest_frontend/components/interactive/PickerFilter.js @@ -1,23 +1,44 @@ -import React from "react" +import React, { useContext } from "react" 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 ( - setTab("hashtag")} disabled={currentTab === - "hashtag"} color={"Grey"} icon={faHashtag} + - setTab("user")} disabled={currentTab === "user"} color={"Grey"} icon={faAt}/> - setTab("location")} disabled={currentTab === - "location"} color={"Grey"} icon={faMapPin} + - setTab("time")} disabled={currentTab === - "time"} color={"Grey"} icon={faClock} + + + ) diff --git a/nest_frontend/components/interactive/PickerVisualization.js b/nest_frontend/components/interactive/PickerVisualization.js index a1f61f7..d04a492 100644 --- a/nest_frontend/components/interactive/PickerVisualization.js +++ b/nest_frontend/components/interactive/PickerVisualization.js @@ -1,24 +1,38 @@ -import React from "react" -import ButtonIconOnly from "../base/ButtonIconOnly" +import React, { useContext } from "react" 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 ( - setTab("stats")} disabled={currentTab === - "stats"} color={"Grey"} icon={faStar} + - setTab("wordcloud")} disabled={currentTab === - "wordcloud"} color={"Grey"} icon={faCloud} + - setTab("histogram")} disabled={currentTab === - "histogram"} color={"Grey"} icon={faChartBar} + + - setTab("map")} disabled={currentTab === "map"} color={"Grey"} icon={faMap}/> ) } diff --git a/nest_frontend/components/providers/RepositoryViewer.js b/nest_frontend/components/providers/RepositoryViewer.js new file mode 100644 index 0000000..84de8e3 --- /dev/null +++ b/nest_frontend/components/providers/RepositoryViewer.js @@ -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 = <> + + + + > + } + else if(repository === null) { + contents = <> + + {strings.repoDeleted} + + > + } + else { + contents = <> + + {repository.name} + + + + + {visualizationTab === "wordcloud" ? : null} + {visualizationTab === "chart" ? : null} + {visualizationTab === "map" ? : null} + {visualizationTab === "stats" ? : null} + + + + {filterTab === "contains" ? : null} + {filterTab === "hashtag" ? : null} + {filterTab === "user" ? : null} + {filterTab === "time" ? "Time" : null} + {filterTab === "location" ? "Location" : null} + > + } + + return ( + + + + {contents} + + + + ) +} diff --git a/nest_frontend/components/providers/RepositoryViewer.module.css b/nest_frontend/components/providers/RepositoryViewer.module.css index 9bc7ea5..c678bc5 100644 --- a/nest_frontend/components/providers/RepositoryViewer.module.css +++ b/nest_frontend/components/providers/RepositoryViewer.module.css @@ -2,15 +2,48 @@ display: grid; grid-template-areas: - "b c d" - "b e e" - "b f f" - "b g g"; - grid-template-columns: 400px 1fr 1fr; - grid-template-rows: auto auto 1fr auto; + "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%; -} \ No newline at end of file +} + +.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; +} + + + + diff --git a/nest_frontend/contexts/ContextLanguage.js b/nest_frontend/contexts/ContextLanguage.js index 8831fff..a48fb29 100644 --- a/nest_frontend/contexts/ContextLanguage.js +++ b/nest_frontend/contexts/ContextLanguage.js @@ -1,4 +1,5 @@ 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 * - `setLang`: a function to change the current language * - `strings`: an object containing all strings of the current language + * + * Defaults to Italian. */ export default createContext({ - lang: null, + lang: "it", setLang: () => console.error("Trying to setLang while outside a ContextServer.Provider!"), - strings: null, + strings: LocalizationStrings.it, }) diff --git a/nest_frontend/hooks/useRepositoryViewer.js b/nest_frontend/hooks/useRepositoryViewer.js new file mode 100644 index 0000000..20e84f5 --- /dev/null +++ b/nest_frontend/hooks/useRepositoryViewer.js @@ -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 +} \ No newline at end of file diff --git a/nest_frontend/hooks/useStrings.js b/nest_frontend/hooks/useStrings.js new file mode 100644 index 0000000..4a02c4a --- /dev/null +++ b/nest_frontend/hooks/useStrings.js @@ -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 +} \ No newline at end of file diff --git a/nest_frontend/routes/PageRepository.js b/nest_frontend/routes/PageRepository.js index 43c501d..4c6441f 100644 --- a/nest_frontend/routes/PageRepository.js +++ b/nest_frontend/routes/PageRepository.js @@ -1,143 +1,12 @@ 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 Loading from "../components/base/Loading" -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" +import RepositoryViewer from "../components/providers/RepositoryViewer" export default function PageRepository({ className, ...props }) { 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 = <> - - - - > - } - else if(repository === null) { - // TODO: Translate this! - contents = <> - - This repository was deleted. - - > - } - else { - contents = <> - - {repository.name} - - - - - - {visualizationTab === "wordcloud" ? - - : null} - {visualizationTab === "histogram" ? - - : null} - {visualizationTab === "map" ? - - : null} - {visualizationTab === "stats" ? - - : null} - - - - - {strings.notImplemented} - - - - {strings.notImplemented} - - > - } return ( - - {contents} - + ) } diff --git a/nest_frontend/routes/PageRepository.module.css b/nest_frontend/routes/PageRepository.module.css deleted file mode 100644 index 2c26ada..0000000 --- a/nest_frontend/routes/PageRepository.module.css +++ /dev/null @@ -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; -} diff --git a/nest_frontend/utils/Filter.js b/nest_frontend/utils/Filter.js new file mode 100644 index 0000000..8e88463 --- /dev/null +++ b/nest_frontend/utils/Filter.js @@ -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()}` + } +} diff --git a/nest_frontend/utils/location.js b/nest_frontend/utils/location.js new file mode 100644 index 0000000..6eea766 --- /dev/null +++ b/nest_frontend/utils/location.js @@ -0,0 +1,37 @@ +export const locationRegex = /[{](?[0-9.]+),(?[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)}` + } +}
{tweet["content"]} @@ -33,7 +29,7 @@ export default function BoxVisualizationMap({ tweets, ...props }) { }) return ( - + {markers} ) diff --git a/nest_frontend/components/interactive/BoxVisualizationStats.js b/nest_frontend/components/interactive/BoxVisualizationStats.js index df11a20..42825b6 100644 --- a/nest_frontend/components/interactive/BoxVisualizationStats.js +++ b/nest_frontend/components/interactive/BoxVisualizationStats.js @@ -1,10 +1,14 @@ -import React, { useMemo } from "react" +import React, { useContext, useMemo } from "react" import FormLabelled from "../base/FormLabelled" import FormLabel from "../base/formparts/FormLabel" +import ContextLanguage from "../../contexts/ContextLanguage" 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( () => tweets.length, @@ -12,8 +16,8 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount, ) const tweetPct = useMemo( - () => tweetCount / totalTweetCount * 100, - [tweetCount, totalTweetCount], + () => tweetCount / rawTweets.length * 100, + [tweetCount, rawTweets], ) const tweetLocationCount = useMemo( @@ -42,12 +46,16 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount, ) 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( () => { + if(words.length === 0) return "❌" return words.sort((wa, wb) => { if(wa.value > wb.value) { return -1 @@ -101,48 +109,47 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount, // TODO: missing stats - // TODO: translate this return ( - + - - {totalTweetCount} + + {rawTweets.length} - + {tweetCount} - + {tweetPct.toFixed(2)}% - + {tweetLocationCount} - + {tweetLocationPct.toFixed(2)}% - + {tweetContentCount} - + {tweetContentPct.toFixed(2)}% - + {wordCount} - + {mostPopularWord} - + 🚧 - + 🚧 - + {uniqueUsersCount} - - {mostActiveUser.user} ({mostActiveUser.count} tweets) + + {mostActiveUser ? `${mostActiveUser.user} (${mostActiveUser.count} tweet${mostActiveUser.count === 1 ? "" : "s"})` : "❌"} diff --git a/nest_frontend/components/interactive/BoxVisualizationWordcloud.js b/nest_frontend/components/interactive/BoxVisualizationWordcloud.js index 780e1a3..543274e 100644 --- a/nest_frontend/components/interactive/BoxVisualizationWordcloud.js +++ b/nest_frontend/components/interactive/BoxVisualizationWordcloud.js @@ -1,12 +1,34 @@ import React, { useContext } from "react" import BoxWordcloud from "../base/BoxWordcloud" 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 {words, appendFilter} = useContext(ContextRepositoryViewer) + + if(words.length === 0) { + return ( + + + + ) + } + + const onWordClick = word => { + appendFilter(new ContainsFilter(false, word.text)) + } return ( - + ) } diff --git a/nest_frontend/components/interactive/ButtonPicker.js b/nest_frontend/components/interactive/ButtonPicker.js new file mode 100644 index 0000000..43029ee --- /dev/null +++ b/nest_frontend/components/interactive/ButtonPicker.js @@ -0,0 +1,14 @@ +import React from "react" +import ButtonIconOnly from "../base/ButtonIconOnly" + + +export default function ButtonPicker({ setTab, currentTab, name, ...props }) { + return ( + setTab(name)} + disabled={currentTab === name} + color={"Grey"} + {...props} + /> + ) +} diff --git a/nest_frontend/components/interactive/FormInlineText.js b/nest_frontend/components/interactive/FormInlineText.js new file mode 100644 index 0000000..660e4aa --- /dev/null +++ b/nest_frontend/components/interactive/FormInlineText.js @@ -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 ( + + + + + ) +} diff --git a/nest_frontend/components/interactive/FormInlineText.module.css b/nest_frontend/components/interactive/FormInlineText.module.css new file mode 100644 index 0000000..f8b3933 --- /dev/null +++ b/nest_frontend/components/interactive/FormInlineText.module.css @@ -0,0 +1,7 @@ +.Input { + flex-shrink: 1; +} + +.Button { + +} \ No newline at end of file diff --git a/nest_frontend/components/interactive/PickerFilter.js b/nest_frontend/components/interactive/PickerFilter.js index ffc1b77..4416abf 100644 --- a/nest_frontend/components/interactive/PickerFilter.js +++ b/nest_frontend/components/interactive/PickerFilter.js @@ -1,23 +1,44 @@ -import React from "react" +import React, { useContext } from "react" 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 ( - setTab("hashtag")} disabled={currentTab === - "hashtag"} color={"Grey"} icon={faHashtag} + - setTab("user")} disabled={currentTab === "user"} color={"Grey"} icon={faAt}/> - setTab("location")} disabled={currentTab === - "location"} color={"Grey"} icon={faMapPin} + - setTab("time")} disabled={currentTab === - "time"} color={"Grey"} icon={faClock} + + + ) diff --git a/nest_frontend/components/interactive/PickerVisualization.js b/nest_frontend/components/interactive/PickerVisualization.js index a1f61f7..d04a492 100644 --- a/nest_frontend/components/interactive/PickerVisualization.js +++ b/nest_frontend/components/interactive/PickerVisualization.js @@ -1,24 +1,38 @@ -import React from "react" -import ButtonIconOnly from "../base/ButtonIconOnly" +import React, { useContext } from "react" 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 ( - setTab("stats")} disabled={currentTab === - "stats"} color={"Grey"} icon={faStar} + - setTab("wordcloud")} disabled={currentTab === - "wordcloud"} color={"Grey"} icon={faCloud} + - setTab("histogram")} disabled={currentTab === - "histogram"} color={"Grey"} icon={faChartBar} + + - setTab("map")} disabled={currentTab === "map"} color={"Grey"} icon={faMap}/> ) } diff --git a/nest_frontend/components/providers/RepositoryViewer.js b/nest_frontend/components/providers/RepositoryViewer.js new file mode 100644 index 0000000..84de8e3 --- /dev/null +++ b/nest_frontend/components/providers/RepositoryViewer.js @@ -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 = <> + + + + > + } + else if(repository === null) { + contents = <> + + {strings.repoDeleted} + + > + } + else { + contents = <> + + {repository.name} + + + + + {visualizationTab === "wordcloud" ? : null} + {visualizationTab === "chart" ? : null} + {visualizationTab === "map" ? : null} + {visualizationTab === "stats" ? : null} + + + + {filterTab === "contains" ? : null} + {filterTab === "hashtag" ? : null} + {filterTab === "user" ? : null} + {filterTab === "time" ? "Time" : null} + {filterTab === "location" ? "Location" : null} + > + } + + return ( + + + + {contents} + + + + ) +} diff --git a/nest_frontend/components/providers/RepositoryViewer.module.css b/nest_frontend/components/providers/RepositoryViewer.module.css index 9bc7ea5..c678bc5 100644 --- a/nest_frontend/components/providers/RepositoryViewer.module.css +++ b/nest_frontend/components/providers/RepositoryViewer.module.css @@ -2,15 +2,48 @@ display: grid; grid-template-areas: - "b c d" - "b e e" - "b f f" - "b g g"; - grid-template-columns: 400px 1fr 1fr; - grid-template-rows: auto auto 1fr auto; + "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%; -} \ No newline at end of file +} + +.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; +} + + + + diff --git a/nest_frontend/contexts/ContextLanguage.js b/nest_frontend/contexts/ContextLanguage.js index 8831fff..a48fb29 100644 --- a/nest_frontend/contexts/ContextLanguage.js +++ b/nest_frontend/contexts/ContextLanguage.js @@ -1,4 +1,5 @@ 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 * - `setLang`: a function to change the current language * - `strings`: an object containing all strings of the current language + * + * Defaults to Italian. */ export default createContext({ - lang: null, + lang: "it", setLang: () => console.error("Trying to setLang while outside a ContextServer.Provider!"), - strings: null, + strings: LocalizationStrings.it, }) diff --git a/nest_frontend/hooks/useRepositoryViewer.js b/nest_frontend/hooks/useRepositoryViewer.js new file mode 100644 index 0000000..20e84f5 --- /dev/null +++ b/nest_frontend/hooks/useRepositoryViewer.js @@ -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 +} \ No newline at end of file diff --git a/nest_frontend/hooks/useStrings.js b/nest_frontend/hooks/useStrings.js new file mode 100644 index 0000000..4a02c4a --- /dev/null +++ b/nest_frontend/hooks/useStrings.js @@ -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 +} \ No newline at end of file diff --git a/nest_frontend/routes/PageRepository.js b/nest_frontend/routes/PageRepository.js index 43c501d..4c6441f 100644 --- a/nest_frontend/routes/PageRepository.js +++ b/nest_frontend/routes/PageRepository.js @@ -1,143 +1,12 @@ 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 Loading from "../components/base/Loading" -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" +import RepositoryViewer from "../components/providers/RepositoryViewer" export default function PageRepository({ className, ...props }) { 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 = <> - - - - > - } - else if(repository === null) { - // TODO: Translate this! - contents = <> - - This repository was deleted. - - > - } - else { - contents = <> - - {repository.name} - - - - - - {visualizationTab === "wordcloud" ? - - : null} - {visualizationTab === "histogram" ? - - : null} - {visualizationTab === "map" ? - - : null} - {visualizationTab === "stats" ? - - : null} - - - - - {strings.notImplemented} - - - - {strings.notImplemented} - - > - } return ( - - {contents} - + ) } diff --git a/nest_frontend/routes/PageRepository.module.css b/nest_frontend/routes/PageRepository.module.css deleted file mode 100644 index 2c26ada..0000000 --- a/nest_frontend/routes/PageRepository.module.css +++ /dev/null @@ -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; -} diff --git a/nest_frontend/utils/Filter.js b/nest_frontend/utils/Filter.js new file mode 100644 index 0000000..8e88463 --- /dev/null +++ b/nest_frontend/utils/Filter.js @@ -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()}` + } +} diff --git a/nest_frontend/utils/location.js b/nest_frontend/utils/location.js new file mode 100644 index 0000000..6eea766 --- /dev/null +++ b/nest_frontend/utils/location.js @@ -0,0 +1,37 @@ +export const locationRegex = /[{](?[0-9.]+),(?[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)}` + } +}