mirror of
https://github.com/pds-nest/nest.git
synced 2024-11-22 04:54:18 +00:00
💥 Refactor RepositoryViewer
This commit is contained in:
parent
a0b65f3378
commit
0a34bab3d3
17 changed files with 520 additions and 242 deletions
27
nest_frontend/components/interactive/BadgeFilter.js
Normal file
27
nest_frontend/components/interactive/BadgeFilter.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import React, { useContext } from "react"
|
||||
import { faAt, faClock, faGlobe, faHashtag, faMapPin } from "@fortawesome/free-solid-svg-icons"
|
||||
import ContextRepositoryEditor from "../../contexts/ContextRepositoryEditor"
|
||||
import Badge from "../base/Badge"
|
||||
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||
|
||||
|
||||
/**
|
||||
* A {@link Badge} representing a Filter.
|
||||
*
|
||||
* @param filter - The Filter that this badge represents.
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export default function BadgeFilter({ filter }) {
|
||||
const {removeFilter} = useContext(ContextRepositoryViewer)
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={filter.color()}
|
||||
icon={filter.icon()}
|
||||
onClickDelete={null}
|
||||
>
|
||||
{filter.text()}
|
||||
</Badge>
|
||||
)
|
||||
}
|
27
nest_frontend/components/interactive/BoxFilters.js
Normal file
27
nest_frontend/components/interactive/BoxFilters.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import React, { useContext } from "react"
|
||||
import BoxFull from "../base/BoxFull"
|
||||
import ContextLanguage from "../../contexts/ContextLanguage"
|
||||
import ContextRepositoryViewer from "../../contexts/ContextRepositoryViewer"
|
||||
import BadgeFilter from "./BadgeFilter"
|
||||
|
||||
|
||||
/**
|
||||
* A box which renders all filters of the {@link ContextRepositoryViewer} as {@link BadgeFilter}s.
|
||||
*
|
||||
* @param props
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export default function BoxFilters({ ...props }) {
|
||||
const { strings } = useContext(ContextLanguage)
|
||||
const {filters} = useContext(ContextRepositoryViewer)
|
||||
|
||||
const badges = filters.map((filter, pos) => <BadgeFilter key={pos} filter={filter}/>)
|
||||
|
||||
// TODO: localize this
|
||||
return (
|
||||
<BoxFull header={"Filters"} {...props}>
|
||||
{badges}
|
||||
</BoxFull>
|
||||
)
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = /[{](?<lat>[0-9.]+),(?<lng>[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 (
|
||||
<Marker key={tweet["snowflake"]} position={[Number.parseFloat(lat), Number.parseFloat(lng)]}>
|
||||
<Marker key={tweet["snowflake"]} position={location.toArray()}>
|
||||
<Popup>
|
||||
<p>
|
||||
{tweet["content"]}
|
||||
|
|
|
@ -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,
|
|||
<BoxFullScrollable header={strings.stats} {...props}>
|
||||
<FormLabelled>
|
||||
<FormLabel text={strings.totTweets}>
|
||||
<b>{totalTweetCount}</b>
|
||||
<b>{rawTweets.length}</b>
|
||||
</FormLabel>
|
||||
<FormLabel text={strings.dispTweets}>
|
||||
<b>{tweetCount}</b>
|
||||
|
|
|
@ -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 (
|
||||
|
|
14
nest_frontend/components/interactive/ButtonPicker.js
Normal file
14
nest_frontend/components/interactive/ButtonPicker.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import React from "react"
|
||||
import ButtonIconOnly from "../base/ButtonIconOnly"
|
||||
|
||||
|
||||
export default function ButtonPicker({ setTab, currentTab, name, ...props }) {
|
||||
return (
|
||||
<ButtonIconOnly
|
||||
onClick={() => setTab(name)}
|
||||
disabled={currentTab === name}
|
||||
color={"Grey"}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<div {...props}>
|
||||
<ButtonIconOnly
|
||||
onClick={() => setTab("hashtag")} disabled={currentTab ===
|
||||
"hashtag"} color={"Grey"} icon={faHashtag}
|
||||
<ButtonPicker
|
||||
currentTab={filterTab}
|
||||
setTab={setFilterTab}
|
||||
name={"contains"}
|
||||
icon={faFont}
|
||||
/>
|
||||
<ButtonIconOnly onClick={() => setTab("user")} disabled={currentTab === "user"} color={"Grey"} icon={faAt}/>
|
||||
<ButtonIconOnly
|
||||
onClick={() => setTab("location")} disabled={currentTab ===
|
||||
"location"} color={"Grey"} icon={faMapPin}
|
||||
<ButtonPicker
|
||||
currentTab={filterTab}
|
||||
setTab={setFilterTab}
|
||||
name={"user"}
|
||||
icon={faAt}
|
||||
/>
|
||||
<ButtonIconOnly
|
||||
onClick={() => setTab("time")} disabled={currentTab ===
|
||||
"time"} color={"Grey"} icon={faClock}
|
||||
<ButtonPicker
|
||||
currentTab={filterTab}
|
||||
setTab={setFilterTab}
|
||||
name={"time"}
|
||||
icon={faClock}
|
||||
/>
|
||||
<ButtonPicker
|
||||
currentTab={filterTab}
|
||||
setTab={setFilterTab}
|
||||
name={"location"}
|
||||
icon={faMapPin}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -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 (
|
||||
<div {...props}>
|
||||
<ButtonIconOnly
|
||||
onClick={() => setTab("stats")} disabled={currentTab ===
|
||||
"stats"} color={"Grey"} icon={faStar}
|
||||
<ButtonPicker
|
||||
currentTab={visualizationTab}
|
||||
setTab={setVisualizationTab}
|
||||
name={"stats"}
|
||||
icon={faStar}
|
||||
/>
|
||||
<ButtonIconOnly
|
||||
onClick={() => setTab("wordcloud")} disabled={currentTab ===
|
||||
"wordcloud"} color={"Grey"} icon={faCloud}
|
||||
<ButtonPicker
|
||||
currentTab={visualizationTab}
|
||||
setTab={setVisualizationTab}
|
||||
name={"wordcloud"}
|
||||
icon={faCloud}
|
||||
/>
|
||||
<ButtonIconOnly
|
||||
onClick={() => setTab("histogram")} disabled={currentTab ===
|
||||
"histogram"} color={"Grey"} icon={faChartBar}
|
||||
<ButtonPicker
|
||||
currentTab={visualizationTab}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
146
nest_frontend/components/providers/RepositoryViewer.js
Normal file
146
nest_frontend/components/providers/RepositoryViewer.js
Normal file
|
@ -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 = <>
|
||||
<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" ? "Contains" : null}
|
||||
{filterTab === "user" ? "User" : null}
|
||||
{filterTab === "time" ? "Time" : null}
|
||||
{filterTab === "location" ? "Location" : null}
|
||||
</>
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextRepositoryViewer.Provider value={{
|
||||
visualizationTab,
|
||||
setVisualizationTab,
|
||||
filterTab,
|
||||
setFilterTab,
|
||||
filters,
|
||||
setFilters,
|
||||
appendFilter,
|
||||
spliceFilter,
|
||||
removeFilter,
|
||||
repositoryBr,
|
||||
repository,
|
||||
rawTweetsBv,
|
||||
rawTweets,
|
||||
tweets,
|
||||
words,
|
||||
}}>
|
||||
|
||||
<div className={classNames(Style.RepositoryViewer, className)} {...props}>
|
||||
{contents}
|
||||
</div>
|
||||
|
||||
</ContextRepositoryViewer.Provider>
|
||||
)
|
||||
}
|
|
@ -2,15 +2,48 @@
|
|||
display: grid;
|
||||
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
15
nest_frontend/hooks/useRepositoryViewer.js
Normal file
15
nest_frontend/hooks/useRepositoryViewer.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { useContext } from "react"
|
||||
import ContextRepositoryEditor from "../contexts/ContextRepositoryEditor"
|
||||
import ContextRepositoryViewer from "../contexts/ContextRepositoryViewer"
|
||||
|
||||
|
||||
/**
|
||||
* Hook to quickly use {@link ContextRepositoryEditor}.
|
||||
*/
|
||||
export default function useRepositoryEditor() {
|
||||
const context = useContext(ContextRepositoryViewer)
|
||||
if(!context) {
|
||||
throw new Error("This component must be placed inside a RepositoryViewer.")
|
||||
}
|
||||
return context
|
||||
}
|
|
@ -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 = <>
|
||||
<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}
|
||||
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 (
|
||||
<div className={classNames(Style.PageRepository, className)} {...props}>
|
||||
{contents}
|
||||
</div>
|
||||
<RepositoryViewer id={id}/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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()}`
|
||||
}
|
||||
}
|
||||
|
|
37
nest_frontend/utils/location.js
Normal file
37
nest_frontend/utils/location.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
export const locationRegex = /[{](?<lat>[0-9.]+),(?<lng>[0-9.]+)[}]/
|
||||
|
||||
|
||||
export class Location {
|
||||
lat
|
||||
lng
|
||||
|
||||
constructor(lat, lng) {
|
||||
this.lat = lat
|
||||
this.lng = lng
|
||||
}
|
||||
|
||||
static fromString(locString) {
|
||||
const match = locationRegex.exec(locString)
|
||||
if(!match) {
|
||||
throw new Error(`Invalid location string: ${locString}`)
|
||||
}
|
||||
const {lat, lng} = match.groups
|
||||
return new Location(lat, lng)
|
||||
}
|
||||
|
||||
static fromTweet(tweet) {
|
||||
if(tweet.location === null) {
|
||||
throw new Error(`Tweet has no location: ${tweet}`)
|
||||
}
|
||||
|
||||
return Location.fromString(tweet.location)
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return [this.lat, this.lng]
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `${this.lat.toFixed(3)} ${this.lng.toFixed(3)}`
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue