diff --git a/nest_frontend/components/interactive/BadgeFilter.js b/nest_frontend/components/interactive/BadgeFilter.js new file mode 100644 index 0000000..dbfaf90 --- /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 ( + + {filter.text()} + + ) +} 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 ec3ac9a..48966a7 100644 --- a/nest_frontend/components/interactive/BoxRepositoryTweets.js +++ b/nest_frontend/components/interactive/BoxRepositoryTweets.js @@ -3,10 +3,12 @@ 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 }) { +export default function BoxRepositoryTweets({ ...props }) { const { strings } = useContext(ContextLanguage) + const {tweets} = useContext(ContextRepositoryViewer) let content if(tweets.length === 0) { diff --git a/nest_frontend/components/interactive/BoxVisualizationChart.js b/nest_frontend/components/interactive/BoxVisualizationChart.js index 1e71f95..b5718e0 100644 --- a/nest_frontend/components/interactive/BoxVisualizationChart.js +++ b/nest_frontend/components/interactive/BoxVisualizationChart.js @@ -3,10 +3,12 @@ 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 }) { +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) diff --git a/nest_frontend/components/interactive/BoxVisualizationMap.js b/nest_frontend/components/interactive/BoxVisualizationMap.js index 7515d7a..6769d2d 100644 --- a/nest_frontend/components/interactive/BoxVisualizationMap.js +++ b/nest_frontend/components/interactive/BoxVisualizationMap.js @@ -2,23 +2,20 @@ import React, { useContext } from "react" import BoxMap from "../base/BoxMap" import ContextLanguage from "../../contexts/ContextLanguage" import { Marker, Popup } from "react-leaflet" +import { Location } from "../../utils/location" +import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer" -const locationRegex = /[{](?[0-9.]+),(?[0-9.]+)[}]/ - -export default function BoxVisualizationMap({ tweets, ...props }) { +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"]} diff --git a/nest_frontend/components/interactive/BoxVisualizationStats.js b/nest_frontend/components/interactive/BoxVisualizationStats.js index 90b608e..42825b6 100644 --- a/nest_frontend/components/interactive/BoxVisualizationStats.js +++ b/nest_frontend/components/interactive/BoxVisualizationStats.js @@ -3,18 +3,21 @@ 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, [tweets], ) const tweetPct = useMemo( - () => tweetCount / totalTweetCount * 100, - [tweetCount, totalTweetCount], + () => tweetCount / rawTweets.length * 100, + [tweetCount, rawTweets], ) const tweetLocationCount = useMemo( @@ -110,7 +113,7 @@ export default function BoxVisualizationStats({ tweets, words, totalTweetCount, - {totalTweetCount} + {rawTweets.length} {tweetCount} diff --git a/nest_frontend/components/interactive/BoxVisualizationWordcloud.js b/nest_frontend/components/interactive/BoxVisualizationWordcloud.js index c61fccd..0c44d2d 100644 --- a/nest_frontend/components/interactive/BoxVisualizationWordcloud.js +++ b/nest_frontend/components/interactive/BoxVisualizationWordcloud.js @@ -3,10 +3,12 @@ import BoxWordcloud from "../base/BoxWordcloud" import ContextLanguage from "../../contexts/ContextLanguage" import BoxFull from "../base/BoxFull" import Empty from "./Empty" +import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer" -export default function BoxVisualizationWordcloud({ words, ...props }) { +export default function BoxVisualizationWordcloud({ ...props }) { const { strings } = useContext(ContextLanguage) + const {words} = useContext(ContextRepositoryViewer) if(words.length === 0) { 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/PickerFilter.js b/nest_frontend/components/interactive/PickerFilter.js index ffc1b77..899166c 100644 --- a/nest_frontend/components/interactive/PickerFilter.js +++ b/nest_frontend/components/interactive/PickerFilter.js @@ -1,23 +1,38 @@ -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..75893a9 --- /dev/null +++ b/nest_frontend/components/providers/RepositoryViewer.js @@ -0,0 +1,146 @@ +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" + + +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" ? "Contains" : null} + {filterTab === "user" ? "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..47ebd4b 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 3fr auto 1fr; 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/hooks/useRepositoryViewer.js b/nest_frontend/hooks/useRepositoryViewer.js new file mode 100644 index 0000000..4ab4914 --- /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 useRepositoryEditor() { + 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/routes/PageRepository.js b/nest_frontend/routes/PageRepository.js index d39e9d5..4c6441f 100644 --- a/nest_frontend/routes/PageRepository.js +++ b/nest_frontend/routes/PageRepository.js @@ -1,142 +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) { - contents = <> - - {strings.repoDeleted} - - - } - 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 index 5551f2b..f9525ce 100644 --- a/nest_frontend/utils/Filter.js +++ b/nest_frontend/utils/Filter.js @@ -1,62 +1,181 @@ -export class Filter { - constructor() { +import { Location } from "../components/interactive/location" +import { + faAt, + faFilter, + faFont, + 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 true + 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(word) { - super() + constructor(negate, word) { + super(negate) this.word = word.toLowerCase().trim() } - exec(tweet) { + check(tweet) { return tweet.content?.toLowerCase().includes(this.word) } + + color() { + return "Grey" + } + + icon() { + return faFont + } + + text() { + return this.word + } } + export class UserFilter extends Filter { user - constructor(user) { - super() + constructor(negate, user) { + super(negate) this.user = user.toLowerCase().trim().replace(/^@/, "") } - exec(tweet) { + 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(hasLocation) { - super() + constructor(negate, hasLocation) { + super(negate) this.hasLocation = hasLocation } - exec(tweet) { + 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(hasPlace) { - super() + constructor(negate, hasPlace) { + super(negate) this.hasPlace = hasPlace } - exec(tweet) { + 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)}` + } +}