diff options
author | Alexander NeonXP Kiryukhin <a.kiryukhin@mail.ru> | 2019-05-14 01:05:07 +0300 |
---|---|---|
committer | Alexander NeonXP Kiryukhin <a.kiryukhin@mail.ru> | 2019-05-14 01:05:07 +0300 |
commit | 0c9db775302d15483385f0621611583e3a2407cd (patch) | |
tree | f1268582b38f66b91b84225c23f6a3b1d7ff0239 /src |
Initial commit
Diffstat (limited to 'src')
-rw-r--r-- | src/Actions/actions.ts | 9 | ||||
-rw-r--r-- | src/Actions/auth.ts | 44 | ||||
-rw-r--r-- | src/Actions/entity.ts | 105 | ||||
-rw-r--r-- | src/Api/api.ts | 251 | ||||
-rw-r--r-- | src/Api/entityDecoder.ts | 55 | ||||
-rw-r--r-- | src/Api/interfaces.ts | 70 | ||||
-rw-r--r-- | src/Api/types.ts | 30 | ||||
-rw-r--r-- | src/Components/Login.tsx | 71 | ||||
-rw-r--r-- | src/Components/Map.tsx | 180 | ||||
-rw-r--r-- | src/Components/MapObjects.tsx | 90 | ||||
-rw-r--r-- | src/Components/MapOverlay.tsx | 91 | ||||
-rw-r--r-- | src/Components/PortalPanel.tsx | 70 | ||||
-rw-r--r-- | src/Main.js | 35 | ||||
-rw-r--r-- | src/Store/store.ts | 63 | ||||
-rw-r--r-- | src/colors.ts | 3 | ||||
-rw-r--r-- | src/helper.js | 46 |
16 files changed, 1213 insertions, 0 deletions
diff --git a/src/Actions/actions.ts b/src/Actions/actions.ts new file mode 100644 index 0000000..988ba65 --- /dev/null +++ b/src/Actions/actions.ts @@ -0,0 +1,9 @@ +import auth from './auth' +import entity from './entity' + +export const actions = { + ...auth, + ...entity, +} + +export default actions
\ No newline at end of file diff --git a/src/Actions/auth.ts b/src/Actions/auth.ts new file mode 100644 index 0000000..fb26c98 --- /dev/null +++ b/src/Actions/auth.ts @@ -0,0 +1,44 @@ +import { Dispatch } from 'redux'; + +const auth = { + 'login': () => (dispatch: Dispatch) => { + dispatch({ + type: 'authLoad', + loading: true, + }) + fetch("https://www.ingress.com/intel", { "credentials": "include", "referrer": "https://intel.ingress.com/" }) + .then(r => r.text()) + .then(t => { + const s1 = t.match(/gen_dashboard_(.+?)\.js/) + if (s1 == null) { + throw new Error("V not found") + } + const v = s1[1] + const s2 = t.match(/var PLAYER = (.+?)\;\n/) + if (s2 == null) { + throw new Error("user not found") + } + const user = JSON.parse(s2[1]) + const s3 = t.match(/\<input type='hidden' name='csrfmiddlewaretoken' value='(.+?)' \/\>/) + if (s3 == null) { + throw new Error("csrf not found") + } + const csrf = s3[1] + return { v, user, csrf } + // }) + // .then(({ v, user }) => { + // return CookieManager.get("https://intel.ingress.com") + // .then(cookies => { + // const csrf = cookies['csrftoken'] + // return { v, csrf, user } + // }) + }).then(({ v, csrf, user }) => + dispatch({ + type: 'authSet', + v, csrf, user + }) + ) + } +} + +export default auth
\ No newline at end of file diff --git a/src/Actions/entity.ts b/src/Actions/entity.ts new file mode 100644 index 0000000..632b505 --- /dev/null +++ b/src/Actions/entity.ts @@ -0,0 +1,105 @@ + +import { Dispatch, Store } from 'redux'; +import { Region } from 'react-native-maps'; +import { getTilesToLoad, loadTiles, getPortalDetails } from '../Api/api'; +import { Portal, Link, Field } from '../Api/types'; +import { LoadedResult } from '../Api/interfaces'; +import { decodePortal } from '../Api/entityDecoder'; + +const entity = { + 'update': (region: Region, width: number, refresh: boolean) => async (dispatch, getStore) => { + const store = getStore() + let tiles = getTilesToLoad(region, width) + if (!refresh) { + const loadedAlready = Object.keys(store.entities.portals) + tiles = tiles.filter(t => loadedAlready.indexOf(t) == -1) + } + const display = store.entities.display + dispatch(entity.loadTiles(tiles, display)) + }, + 'loadTiles': (tiles: string[], display: any) => async (dispatch, getStore) => { + const store = getStore() + const { v, csrf } = store.auth + const params = { v, csrf } + dispatch(entity.entitiesLoad(tiles.length)) + setImmediate(() => loadTiles(tiles, params) + .then((loadedData: LoadedResult) => dispatch(entity.receiveTiles(loadedData, display)))) + }, + 'receiveTiles': (loadedData: LoadedResult, display: any) => async (dispatch) => { + dispatch(entity.portalsSet(loadedData.portals)) + dispatch(entity.linksSet(loadedData.links)) + dispatch(entity.fieldsSet(loadedData.fields)) + Object.keys(loadedData.portalsByTile).forEach(tile => { + loadedData.portalsByTile[tile].forEach(p => { + if (display.portals.indexOf(p) == -1) { + display.portals.push(p) + } + }) + }) + Object.keys(loadedData.linksByTile).forEach(tile => { + loadedData.linksByTile[tile].forEach(p => { + if (display.links.indexOf(p) == -1) { + display.links.push(p) + } + }) + }) + Object.keys(loadedData.fieldsByTile).forEach(tile => { + loadedData.fieldsByTile[tile].forEach(p => { + if (display.fields.indexOf(p) == -1) { + display.fields.push(p) + } + }) + }) + dispatch(entity.entitiesDisplay(display)) + if (loadedData.failedTiles.length > 0) { + setImmediate(() => { + dispatch(entity.loadTiles(loadedData.failedTiles, display)) + }) + } else { + dispatch(entity.entitiesLoad(0)) + } + }, + 'getPortalDetails': (guid: string) => async (dispatch, getStore) => { + const store = getStore() + const { v, csrf } = store.auth + const params = { v, csrf } + getPortalDetails(guid, params).then(j => { + const portal = decodePortal(j.result) + portal.fullLoad = true + dispatch(entity.portalSet(guid, portal)) + }) + }, + 'entitiesDisplay': (display: any) => ({ + type: 'entitiesDisplay', + display + }), + 'entitiesLoad': (loading: number) => ({ + type: 'entitiesLoad', + loading + }), + 'portalsSet': (portals: { [guid: string]: Portal }) => ({ + type: 'portalsSet', + portals, + }), + 'linksSet': (links: { [guid: string]: Link }) => ({ + type: 'linksSet', + links, + }), + 'fieldsSet': (fields: { [guid: string]: Field }) => ({ + type: 'fieldsSet', + fields, + }), + 'portalSet': (guid: string, portal: Portal) => ({ + type: 'portalSet', + guid, portal, + }), + 'linkSet': (guid: string, link: Link) => ({ + type: 'linksSet', + guid, link, + }), + 'fieldSet': (guid: string, field: Field) => ({ + type: 'fieldsSet', + guid, field, + }) +} +export default entity
\ No newline at end of file diff --git a/src/Api/api.ts b/src/Api/api.ts new file mode 100644 index 0000000..2da4594 --- /dev/null +++ b/src/Api/api.ts @@ -0,0 +1,251 @@ +import { Region, LoadedResult, BoundingBox, TileParameters, GetEntitiesResponse, GetPortalResponse } from "./interfaces"; +import { Portal, Field, Link } from "./types"; +import { decodeEntity } from "./entityDecoder"; + +const baseHeaders = { + 'Content-Type': 'application/json; charset=UTF-8', + 'Pragma': 'no-cache', + 'Accept': '*/*', + 'Host': 'intel.ingress.com', + 'Accept-Language': 'ru', + 'Cache-Control': 'no-cache', + 'Accept-Encoding': 'br, gzip, deflate', + 'Origin': 'https://intel.ingress.com', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1 Safari/605.1.15', + 'Referer': 'https://intel.ingress.com/', +} + + + +//getPlexts : {"minLatE6":55740916,"minLngE6":49151362,"maxLatE6":55752993,"maxLngE6":49221056,"minTimestampMs":-1,"maxTimestampMs":-1,"tab":"all","v":"93c872fd0763076709bed329e421c37f5fa45fa4"} +//getPortalDetails : {"guid":"e9ac08ad60db42abb948970defb28548.16","v":"93c872fd0763076709bed329e421c37f5fa45fa4"} + +type Cache = { + portals: { + [tile: string]: string[] + }, + links: { + [tile: string]: string[] + }, + fields: { + [tile: string]: string[] + } +} +const cache: Cache = { + fields: {}, + links: {}, + portals: {}, +} +const ZOOM_TO_LEVEL = [8, 8, 8, 8, 7, 7, 7, 6, 6, 5, 4, 4, 3, 2, 2, 1, 1] +const TILES_PER_EDGE = [1, 1, 1, 40, 40, 80, 80, 320, 1000, 2000, 2000, 4000, 8000, 16000, 16000, 32000] +const ZOOM_TO_LINK_LENGTH = [200000, 200000, 200000, 200000, 200000, 60000, 60000, 10000, 5000, 2500, 2500, 800, 300, 0, 0] + +export const getTilesToLoad = (region: Region, width: number): string[] => { + const zoom = getZoomByRegion(width, region) + const dataZoom = getDataZoomForMapZoom(zoom); + const tileParams = getMapZoomTileParameters(dataZoom) + const bbox = getBoundingBox(region) + const x1 = lngToTile(bbox.west, tileParams); + const x2 = lngToTile(bbox.east, tileParams); + const y1 = latToTile(bbox.north, tileParams); + const y2 = latToTile(bbox.south, tileParams); + let tilesToLoad: string[] = [] + for (var y = y1; y <= y2; y++) { + for (var x = x1; x <= x2; x++) { + const tile_id = pointToTileId(x, y, tileParams); + tilesToLoad.push(tile_id) + } + } + return tilesToLoad +} + +export const loadTiles = async (tilesToLoad: string[], params: RequestParams): Promise<LoadedResult> => { + const chunk = tilesToLoad.splice(0, 25) + const loadedData: LoadedResult = { + fields: {}, + links: {}, + portals: {}, + portalsByTile: {}, + fieldsByTile: {}, + linksByTile: {}, + failedTiles: tilesToLoad, + loaded: false, + } + await getEntities(chunk, params) + .then(loaded => { + const result = loaded.result + if (!result || !result['map']) { + return false + } + return Object.keys(result['map']).map(tile => { + if (result['map'][tile]['error'] || !result['map'][tile]['gameEntities']) { + loadedData.failedTiles.push(tile) + return true + } + if (!loadedData.portalsByTile[tile]) { + loadedData.portalsByTile[tile] = [] + } + if (!loadedData.fieldsByTile[tile]) { + loadedData.fieldsByTile[tile] = [] + } + if (!loadedData.linksByTile[tile]) { + loadedData.linksByTile[tile] = [] + } + return result['map'][tile]['gameEntities'].map((e: any[]) => { + const guid = e[0] + const de = decodeEntity(e) + if (de instanceof Portal) { + if (loadedData.portalsByTile[tile].indexOf(guid) == -1) { + loadedData.portalsByTile[tile].push(guid) + } + loadedData.portals[guid] = de + } else if (de instanceof Field) { + if (loadedData.fieldsByTile[tile].indexOf(guid) == -1) { + loadedData.fieldsByTile[tile].push(guid) + } + loadedData.fields[guid] = de + } else if (de instanceof Link) { + if (loadedData.linksByTile[tile].indexOf(guid) == -1) { + loadedData.linksByTile[tile].push(guid) + } + loadedData.links[guid] = de + } + return true + }) + }) + }).catch(e => { + loadedData.failedTiles = [...loadedData.failedTiles, ...chunk] + }) + return loadedData +} + +const getBoundingBox = (region: Region): BoundingBox => { + return { + east: region.longitude + (region.longitudeDelta / 2), + north: region.latitude + (region.latitudeDelta / 2), + south: region.latitude - (region.latitudeDelta / 2), + west: region.longitude - (region.longitudeDelta / 2), + } +} + +const lngToTile = (lng: any, tileParams: TileParameters) => { + return Math.floor((lng + 180) / 360 * tileParams.tilesPerEdge); +} + +const latToTile = (lat: any, tileParams: TileParameters) => { + return Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * tileParams.tilesPerEdge); +} + +const tileToLng = (x: number, params: TileParameters): number => { + return x / params.tilesPerEdge * 360 - 180; +} + +const tileToLat = (y: number, params: TileParameters): number => { + var n = Math.PI - 2 * Math.PI * y / params.tilesPerEdge; + return 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); +} + +const pointToTileId = (x: number, y: number, params: TileParameters): string => { + //change to quadkey construction + //as of 2014-05-06: zoom_x_y_minlvl_maxlvl_maxhealth + + return params.zoom + "_" + x + "_" + y + "_" + params.level + "_8_100"; +} + +const getZoomByRegion = (width: number, region: Region): number => { + return Math.ceil(Math.log2(360 * ((width / 256) / region.longitudeDelta))) + 1 +} + +const getDataZoomForMapZoom = (zoom: number): number => { + if (zoom > 21) { + zoom = 21; + } + var origTileParams = getMapZoomTileParameters(zoom); + while (zoom > 3) { + var newTileParams = getMapZoomTileParameters(zoom - 1); + + if (newTileParams.tilesPerEdge != origTileParams.tilesPerEdge + || newTileParams.hasPortals != origTileParams.hasPortals + || newTileParams.level * (newTileParams.hasPortals ? 1 : 0) != origTileParams.level * (origTileParams.hasPortals ? 1 : 0) // multiply by 'hasPortals' bool - so comparison does not matter when no portals available + ) { + // switching to zoom-1 would result in a different detail level - so we abort changing things + break; + } else { + // changing to zoom = zoom-1 results in identical tile parameters - so we can safely step back + // with no increase in either server load or number of requests + zoom = zoom - 1; + } + } + return zoom; +} + +const getMapZoomTileParameters = (zoom: number): TileParameters => { + var level = ZOOM_TO_LEVEL.slice(0, 15)[zoom] || 0; + + if (level <= 7 && level >= 4) { + level = level + 1; + } + + var maxTilesPerEdge = TILES_PER_EDGE[TILES_PER_EDGE.length - 1]; + + return { + level: level, + maxLevel: ZOOM_TO_LEVEL.slice(0, 15)[zoom] || 0, // for reference, for log purposes, etc + tilesPerEdge: TILES_PER_EDGE[zoom] || maxTilesPerEdge, + minLinkLength: ZOOM_TO_LINK_LENGTH[zoom] || 0, + hasPortals: zoom >= ZOOM_TO_LINK_LENGTH.length, // no portals returned at all when link length limits things + zoom: zoom // include the zoom level, for reference + }; +} + +type RequestParams = { + csrf: string, v: string +} + +function timeout(ms, promise) { + return new Promise(function (resolve, reject) { + setTimeout(function () { + reject(new Error("timeout")) + }, ms) + return promise.then(resolve, reject) + }) +} + +const getEntities = (tileKeys: string[], params: RequestParams): Promise<GetEntitiesResponse> => { + const promise = fetch("https://intel.ingress.com/r/getEntities", { + "credentials": 'include', + "headers": { + ...baseHeaders, + 'X-CSRFToken': params.csrf, + }, + "referrer": "https://intel.ingress.com/", + "body": JSON.stringify({ tileKeys, v: params.v }), + "method": "POST", + }) + return timeout(10000, promise.then(r => { + if (r.status != 200) { + return { result: { map: {} } } + } + return r.json() + })) +} + +export const getPortalDetails = (guid: string, params: RequestParams): Promise<GetPortalResponse> => { + const promise = fetch("https://intel.ingress.com/r/getPortalDetails", { + "credentials": 'include', + "headers": { + ...baseHeaders, + 'X-CSRFToken': params.csrf, + }, + "referrer": "https://intel.ingress.com/", + "body": JSON.stringify({ guid, v: params.v }), + "method": "POST", + }) + return timeout(10000, promise.then(r => { + if (r.status != 200) { + return { result: [] } + } + return r.json() + })) +} diff --git a/src/Api/entityDecoder.ts b/src/Api/entityDecoder.ts new file mode 100644 index 0000000..96d2247 --- /dev/null +++ b/src/Api/entityDecoder.ts @@ -0,0 +1,55 @@ +import { Portal, Link, Field } from "./types"; + +export const decodeEntity = (entity: any[]): Portal | Link | Field | null => { + switch (entity[2][0]) { + case 'r': + // Field + return decodeField(entity[2]) + case 'p': + //Portal + return decodePortal(entity[2]) + case 'e': + // Link + return decodeLink(entity[2]) + default: + return null + } +} + +export const decodePortal = (e: any[]): Portal => { + return new Portal( + e[8], + e[7], + e[1], + { latitude: e[2] / 1E6, longitude: e[3] / 1E6, guid: e[8] }, + e[4], + e[6], + e[10], + e[5], + e[13], + e[15], + e[14], + e[16], + ) +} + +export const decodeField = (e: any[]): Field => { + return new Field( + e[1], + [ + { latitude: e[2][0][1] / 1E6, longitude: e[2][0][2] / 1E6, guid: e[2][0][0] }, + { latitude: e[2][1][1] / 1E6, longitude: e[2][1][2] / 1E6, guid: e[2][1][0] }, + { latitude: e[2][2][1] / 1E6, longitude: e[2][2][2] / 1E6, guid: e[2][2][0] } + ] + ) +} + +export const decodeLink = (e: any[]): Link => { + return new Link( + e[1], + [ + { latitude: e[3] / 1E6, longitude: e[4] / 1E6, guid: e[2] }, + { latitude: e[6] / 1E6, longitude: e[7] / 1E6, guid: e[5] } + ] + ) +}
\ No newline at end of file diff --git a/src/Api/interfaces.ts b/src/Api/interfaces.ts new file mode 100644 index 0000000..779fcf1 --- /dev/null +++ b/src/Api/interfaces.ts @@ -0,0 +1,70 @@ +import { Portal, Link, Field } from "./types"; + +export interface TileParameters { + level: number, + maxLevel: number, + tilesPerEdge: number, + minLinkLength: number, + hasPortals: boolean + zoom: number, +} + +export interface BoundingBox { + west: number + east: number + north: number + south: number +} + +export interface GetEntitiesResponse { + result: { + map: { + [tile: string]: { + gameEntities: any[] + error: string + } + } + } +} + +export interface GetPortalResponse { + result: any[] +} + +export interface LoadedResult { + loaded: boolean, + portalsByTile: { + [tile: string]: string[] + }, + linksByTile: { + [tile: string]: string[] + }, + fieldsByTile: { + [tile: string]: string[] + }, + portals: { + [guid: string]: Portal + }, + links: { + [guid: string]: Link + }, + fields: { + [guid: string]: Field + }, + failedTiles: string[] +} + + +export interface LatLng { + guid: string; + latitude: number; + longitude: number; + +} + +export interface Region { + readonly latitude: number; + readonly longitude: number; + readonly latitudeDelta: number; + readonly longitudeDelta: number; +}
\ No newline at end of file diff --git a/src/Api/types.ts b/src/Api/types.ts new file mode 100644 index 0000000..7a26bc8 --- /dev/null +++ b/src/Api/types.ts @@ -0,0 +1,30 @@ +import { LatLng } from "./interfaces"; + +export class Portal { + fullLoad: boolean + constructor( + public name: string, + public photo: string, + public fraction: string, + public coords: LatLng, + public level: number, + public resonatorsCount: number, + public mission: boolean, + public power: number, + public timestamp: number, + public resonators: any[], + public mods: any[], + public owner: string + + ) { + this.fullLoad = false + } +} + +export class Link { + constructor(public fraction: string, public coords: LatLng[]) { } +} + +export class Field { + constructor(public fraction: string, public coords: LatLng[]) { } +}
\ No newline at end of file diff --git a/src/Components/Login.tsx b/src/Components/Login.tsx new file mode 100644 index 0000000..6fe7957 --- /dev/null +++ b/src/Components/Login.tsx @@ -0,0 +1,71 @@ +import React, { Component } from 'react'; +import { StyleSheet, Text, View, WebView } from 'react-native'; + +import { connect } from 'redux-su'; +import { getBottomSpace, getStatusBarHeight } from '../helper'; +import actions from '../Actions/actions'; + +const LOGIN_URL = "https://accounts.google.com/ServiceLogin?service=ah&passive=true&continue=https://appengine.google.com/_ah/conflogin%3Fcontinue%3Dhttps://intel.ingress.com/"; +const HOME_URL = "https://intel.ingress.com/"; + +var styles = StyleSheet.create({ + container: { + flexGrow: 1, + } +}); + +type Props = { + login(csrf: string, v: string): void + actions: any + auth: any +} + +type State = { + v: string + onIngress: boolean +} + +class Login extends Component<Props, State> { + webview!: WebView; + constructor(props: Props) { + super(props); + this.state = { v: "", onIngress: false } + } + + onNavigationStateChange(navState: WebViewNavigation) { + if (navState.url == HOME_URL) { + this.setState({ onIngress: true }) + this.props.actions.login() + } + } + + renderLogin() { + if (this.state.onIngress) { + return (<Text>Пожалуйста, подождите...</Text>); + } + return ( + <> + <WebView + ref={r => r && (this.webview = r)} + automaticallyAdjustContentInsets={false} + thirdPartyCookiesEnabled + useWebKit + style={styles.container} + source={{ uri: LOGIN_URL }} + javaScriptEnabled={true} + onNavigationStateChange={this.onNavigationStateChange.bind(this)} + startInLoadingState={true} + /> + </> + ); + } + + render() { + console.log(this.props) + return (<View style={{ flexGrow: 1, paddingTop: getStatusBarHeight(true), paddingBottom: getBottomSpace() }}> + {this.renderLogin()} + </View>); + } +} + +export default connect({ 'auth': 'auth' }, actions)(Login)
\ No newline at end of file diff --git a/src/Components/Map.tsx b/src/Components/Map.tsx new file mode 100644 index 0000000..3e878f8 --- /dev/null +++ b/src/Components/Map.tsx @@ -0,0 +1,180 @@ +import React, { Component } from 'react'; +import { StyleSheet, View, Dimensions, ActivityIndicator } from 'react-native'; +import MapView, { Marker, Region, UrlTile } from 'react-native-maps'; +import { connect } from 'redux-su'; +import { NavigationActions } from 'react-navigation'; + +import MapOverlay from './MapOverlay'; +import MapObjects from './MapObjects'; +import PortalPanel from './PortalPanel'; +import { getBottomSpace } from '../helper'; +import actions from '../Actions/actions'; +import { LatLng } from '../Api/interfaces'; + +const { width, height } = Dimensions.get("screen") +const draggableRange = { + top: height / 1.75, + bottom: 120 + getBottomSpace() +} + +type Props = any +type State = any +class Map extends Component<Props, State> { + refresh: number | undefined + map!: MapView; + constructor(props: Props) { + super(props) + this.state = { + user: undefined, + followUser: true, + zoom: 15, + region: null, + loading: false, + objects: { links: {}, fields: {}, portals: {}, loaded: false }, + } + } + componentWillMount() { + navigator.geolocation.getCurrentPosition( + position => { + this.setState({ + followUser: true, + }); + }, + error => alert(error.message), + { enableHighAccuracy: true, timeout: 20000, maximumAge: 1000 } + ); + navigator.geolocation.watchPosition( + position => { + this.setState({ + user: { + latitude: position.coords.latitude, + longitude: position.coords.longitude + }, + }); + }, + error => alert(error.message), + { enableHighAccuracy: true, timeout: 20000, maximumAge: 1000 } + ) + } + onRegionChange = (region: Region) => { + const zoom = this.getZoomByRegion(width, region) + this.setState({ region, zoom }) + setImmediate(() => this.load(false)) + } + getZoomByRegion(width: number, region: Region): number { + return Math.ceil(Math.log2(360 * ((width / 256) / region.longitudeDelta))) + 1 + } + load = async (refresh: boolean) => { + if (this.state.region != null && this.props.entities.loading == 0) { + this.props.actions.update(this.state.region, width, refresh) + } + return null + } + + refreshByTime = () => { + setTimeout(() => { + this.load(true).then(this.refreshByTime) + }, 30000) + } + + componentDidMount() { + this.refreshByTime() + } + + onPortalClick = (guid: string, coords: LatLng) => { + this.setState({ selectedPortal: { guid, coords } }) + } + + onOpenPortal = (guid: string, coords: LatLng) => { + const navigateAction = NavigationActions.navigate({ + routeName: 'Portal', + params: { guid, coords }, + }); + this.props.navigation.dispatch(navigateAction); + } + + render() { + if (!this.state.user) { + return <View style={styles.spinnerContainer}><ActivityIndicator size={'large'} /></View> + } + const camera = { + center: this.state.user, + altitude: 1000, + heading: 0, + pitch: 30, + zoom: 15, + } + const goToMe = () => { + this.map.animateToCoordinate(this.state.user) + } + const refresh = () => { + setImmediate(() => this.load(true)) + } + return ( + <> + <MapView + ref={r => (r != null) ? this.map = r : null} + style={styles.container} + initialRegion={{ ...this.state.user, latitudeDelta: 0.002, longitudeDelta: 0.002 }} + onRegionChangeComplete={this.onRegionChange} + showsCompass={false} + showsScale + showsUserLocation + showsMyLocationButton + loadingEnabled + type={'hybrid'} + > + <MapObjects onPortalClick={this.onPortalClick} zoom={this.state.zoom} /> + {this.state.selectedPortal && <Marker coordinate={this.state.selectedPortal.coords} />} + </MapView> + <MapOverlay + goToMe={goToMe} + refresh={refresh} + loading={this.props.entities.loading} + selectedPortal={this.state.selectedPortal} + onOpenPortal={this.onOpenPortal} + /> + </> + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + spinnerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + panel: { + flex: 1, + backgroundColor: '#fff', + position: 'relative', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + //ios + shadowOpacity: 0.3, + shadowRadius: 3, + shadowOffset: { + height: 0, + width: 0 + }, + //android + elevation: 1 + }, + panelHeader: { + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + dash: { + backgroundColor: 'rgba(200,200,200,0.9)', + height: 6, + width: 30, + borderRadius: 3 + } +}); + +export default connect({ 'entities': 'entities' }, actions)(Map)
\ No newline at end of file diff --git a/src/Components/MapObjects.tsx b/src/Components/MapObjects.tsx new file mode 100644 index 0000000..1d2fb12 --- /dev/null +++ b/src/Components/MapObjects.tsx @@ -0,0 +1,90 @@ +import React, { ReactChild } from 'react'; +import { View, StyleSheet, Text } from 'react-native'; +import { Polyline, Polygon, Marker, Region } from 'react-native-maps'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import { Link, Portal, Field } from '../Api/types'; +import { connect } from 'react-redux'; +import { COLORS, COLORS_LVL } from '../colors'; + +const fillPortalColor = { "R": "rgba(2, 140, 227, 0.5)", "E": "rgba(38, 205, 30, 0.5)", "N": "rgba(139, 0, 255, 0.5)" } +// const fillPortalColor = { "R": COLORS[1], "E": COLORS[2], "N": COLORS[0] } +const fractColor = { "R": "#028ce3", "E": "#26cd1e", "N": "#8b00ff" } +// const fractColor = { "R": COLORS[1], "E": COLORS[2], "N": COLORS[0] } +const fieldColor = { "R": "rgba(2, 140, 227, 0.1)", "E": "rgba(38, 205, 30, 0.1)", "N": "rgba(139, 0, 255, 0.1)" } + +type Props = { + portals: { [guid: string]: Portal } + links: { [guid: string]: Link } + fields: { [guid: string]: Field } + onPortalClick: Function + zoom: number +} + +const MapObjects = (props: Props) => { + const renderPortal = Object.keys(props.portals).map(guid => drawPortal(guid, props.portals[guid], props.zoom, props.onPortalClick)) + const renderField = Object.keys(props.fields).map(guid => drawField(guid, props.fields[guid])) + const renderLink = Object.keys(props.links).map(guid => drawLink(guid, props.links[guid])) + + if (props.zoom <= 14) { + return [...renderField, ...renderLink] + } else { + return [...renderField, ...renderLink, ...renderPortal] + } +} +const drawPortal = (guid: string, entity: Portal, zoom: number, onPortalClick: Function) => { + const size = (zoom) * 1.5 + return (<Marker + key={guid} + coordinate={entity.coords} + onPress={() => onPortalClick(guid, entity.coords)} + > + {/* <Icon name={entity.fraction == "N" ? "circle-o" : "circle"} color={fillPortalColor[entity.fraction]} size={size} /> */} + <View style={{ + borderWidth: 2, + height: size, + width: size, + borderRadius: size / 2, + borderColor: COLORS_LVL[entity.level], + backgroundColor: fillPortalColor[entity.fraction], + justifyContent: 'center', + alignItems: 'center', + }}> + <Text style={{ fontWeight: 'bold' }}>{entity.level}</Text> + </View> + </Marker>); +} +const drawField = (guid: string, entity: Field) => { + return <Polygon + key={guid} + coordinates={entity.coords} + fillColor={fieldColor[entity.fraction]} + strokeColor={fieldColor[entity.fraction]} + strokeWidth={StyleSheet.hairlineWidth} + /> +} +const drawLink = (guid: string, entity: Link) => { + return <Polyline + key={guid} + coordinates={entity.coords} + strokeColor={fractColor[entity.fraction]} + strokeWidth={1} + /> +} + + +const styles = StyleSheet.create({ + +}); + +export default connect((store) => { + const display = store.entities.display + const portals = {} + const fields = {} + const links = {} + display.portals.forEach(guid => { portals[guid] = store.entities.portals[guid] }) + display.fields.forEach(guid => { fields[guid] = store.entities.fields[guid] }) + display.links.forEach(guid => { links[guid] = store.entities.links[guid] }) + return { + portals, fields, links + } +}, {})(MapObjects)
\ No newline at end of file diff --git a/src/Components/MapOverlay.tsx b/src/Components/MapOverlay.tsx new file mode 100644 index 0000000..30160c9 --- /dev/null +++ b/src/Components/MapOverlay.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { StyleSheet, View, Text, GestureResponderEvent, ActivityIndicator, Linking } from 'react-native'; +import { FontAwesome } from '@expo/vector-icons'; +import { getStatusBarHeight } from '../helper'; +import { LatLng } from '../Api/interfaces'; + +type Props = { + goToMe: (e: GestureResponderEvent) => void; + refresh: (e: GestureResponderEvent) => void; + onOpenPortal: (guid: string, coords: LatLng) => void; + loading: Number; + selectedPortal: any; +} + +export default (props: Props) => ( + <View style={styles.overlay}> + <View style={styles.button}> + <FontAwesome.Button + name={'crosshairs'} + onPress={props.goToMe} + iconStyle={styles.icon} + color={"#000"} + size={24} + backgroundColor={"#e3e3e3"} + /> + </View> + <View style={styles.button}> + <FontAwesome.Button + name={'refresh'} + onPress={props.refresh} + iconStyle={styles.icon} + color={"#000"} + size={24} + backgroundColor={"#e3e3e3"} + /> + </View> + {props.selectedPortal ? ( + <> + <View style={styles.button}> + <FontAwesome.Button + name={'info'} + onPress={() => { + props.onOpenPortal(props.selectedPortal.guid, props.selectedPortal.coords) + }} + iconStyle={styles.icon} + color={"#000"} + size={24} + backgroundColor={"#e3e3e3"} + /> + </View> + <View style={styles.button}> + <FontAwesome.Button + name={'location-arrow'} + onPress={() => Linking.openURL(`geo:${props.selectedPortal.coords.latitude},${props.selectedPortal.coords.longitude}`)} + iconStyle={styles.icon} + color={"#000"} + size={24} + backgroundColor={"#e3e3e3"} + /> + </View> + </> + ) : null} + + <View style={styles.button}> + <Text>{props.loading}</Text> + </View> + {props.loading > 0 ? ( + <View style={styles.loader}> + <ActivityIndicator color={"#000"} /> + </View> + ) : null} + </View> +); + +const styles = StyleSheet.create({ + overlay: { + position: 'absolute', + top: 8 + getStatusBarHeight(true), + right: 8, + }, + button: { + margin: 8, + }, + icon: { + marginRight: 0, + }, + loader: { + margin: 8, + paddingHorizontal: 8, + } +});
\ No newline at end of file diff --git a/src/Components/PortalPanel.tsx b/src/Components/PortalPanel.tsx new file mode 100644 index 0000000..6cf19c2 --- /dev/null +++ b/src/Components/PortalPanel.tsx @@ -0,0 +1,70 @@ +import React, { Component, PureComponent } from 'react'; +import { StyleSheet, View, Text, GestureResponderEvent, ActivityIndicator } from 'react-native'; +// import { Button } from 'react-native-vector-icons/FontAwesome'; +import Reactotron from 'reactotron-react-native' +import { getStatusBarHeight } from '../helper'; +import { connect } from 'react-redux'; +import actions from '../Actions/actions'; +import { Portal } from '../Api/types'; +import { bindActionCreators } from 'redux'; + +type Props = { + guid: string + portal?: Portal +} + +class PortalPanel extends PureComponent<Props> { + static navigationOptions = ({ navigation }) => { + return { + title: navigation.getParam('title', 'Загрузка...'), + }; + }; + componentWillMount() { + this.props.navigation.setParams({title: this.props.portal.name}) + } + componentDidMount() { + this.props.getPortalDetails(this.props.guid) + } + componentWillReceiveProps(next: Props) { + if (next.guid != this.props.guid) { + this.props.navigation.setParams({title: this.props.portal.name}) + this.props.getPortalDetails(next.guid) + } + } + render() { + const { portal } = this.props + if (!portal) { + return <ActivityIndicator /> + } + return ( + <View style={styles.overlay}> + <Text style={styles.title}>{portal.name}</Text> + <Text style={styles.subtitle}>Уровeнь: {portal.level}, здоровье: {portal.power}</Text> + <Text>{JSON.stringify(this.props)}</Text> + </View> + ); + } +} + +export default connect((store, props: Props) => { + const guid = props.navigation.getParam('guid') + return { + portal: store.entities.portals[guid], + guid + } +}, (dispatch) => bindActionCreators(actions, dispatch))(PortalPanel) + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + padding: 8, + }, + title: { + fontWeight: 'bold', + fontSize: 22, + }, + subtitle: { + fontWeight: 'normal', + fontSize: 18, + } +});
\ No newline at end of file diff --git a/src/Main.js b/src/Main.js new file mode 100644 index 0000000..5a67c64 --- /dev/null +++ b/src/Main.js @@ -0,0 +1,35 @@ +import React, { Component } from 'react'; +import { Platform, StyleSheet, Text, View } from 'react-native'; +import { connect } from 'redux-su'; +import Login from './Components/Login'; +import Map from './Components/Map'; +import PortalPanel from './Components/PortalPanel'; +import { createStackNavigator, createAppContainer } from 'react-navigation'; + +const AppNavigator = createStackNavigator({ + Map: { + screen: Map, + navigationOptions: { + header: null, + } + }, + Portal: { + screen: PortalPanel, + navigationOptions: () => ({ + headerMode: 'float', + headerBackTitle: null + }) + }, +}); +const AppContainer = createAppContainer(AppNavigator) +class App extends Component { + render() { + return ( + <View style={{ flex: 1 }}> + {this.props.auth.user ? <AppContainer /> : <Login />} + </View> + ); + } +} + +export default connect({ 'auth': 'auth' }, {})(App)
\ No newline at end of file diff --git a/src/Store/store.ts b/src/Store/store.ts new file mode 100644 index 0000000..0929882 --- /dev/null +++ b/src/Store/store.ts @@ -0,0 +1,63 @@ +import { createStore, combineReducers, applyMiddleware } from 'redux'; +import thunk from 'redux-thunk'; +import { createReducer } from 'redux-su'; +import { composeWithDevTools } from 'redux-devtools-extension'; +import { Portal, Link, Field } from '../Api/types'; + +type Display = { + portals: string[], + fields: string[], + links: string[] +} + +const reducers = { + 'auth': createReducer({ + 'authSet': (store: any, action: { v: string, csrf: string, user: any }) => ({ v: action.v, csrf: action.csrf, user: action.user, loading: false }), + 'authLoad': (store: any, action: { loading: boolean }) => ({ ...store, loading: action.loading }) + }, { v: "", csrf: "", user: null, loading: false }), + 'entities': createReducer({ + 'portalSet': (store: any, action: { guid: string, portal: Portal }) => + ({ ...store, portals: { ...store.portals, [action.guid]: action.portal } }), + 'linkSet': (store: any, action: { guid: string, link: Link }) => + ({ ...store, links: { ...store.links, [action.guid]: action.link } }), + 'fieldSet': (store: any, action: { guid: string, field: Field }) => + ({ ...store, fields: { ...store.fields, [action.guid]: action.field } }), + 'portalsSet': (store: any, action: { portals: Portal }) => { + const portals = store.portals + Object.keys(action.portals).forEach(guid => { + const portal: Portal = portals[guid] + const newPortal: Portal = action.portals[guid] + if (!portal) { + portals[guid] = newPortal + return + } + portals[guid] = extend(portal, newPortal) + }) + return { ...store, portals } + }, + 'linksSet': (store: any, action: { links: { [guid: string]: Link } }) => + ({ ...store, links: { ...store.links, ...action.links } }), + 'fieldsSet': (store: any, action: { fields: { [guid: string]: Field } }) => + ({ ...store, fields: { ...store.fields, ...action.fields } }), + 'entitiesLoad': (store: any, action: { loading: number }) => + ({ ...store, loading: action.loading }), + 'entitiesDisplay': (store: any, action: { display: Display }) => + ({ ...store, display: action.display }), + + }, { portals: {}, fields: {}, links: {}, loading: 0, display: { portals: [], fields: [], links: [] } }) +} + +function extend(obj: any, src: any) { + for (var key in src) { + if (src.hasOwnProperty(key) && !!src[key]) { + obj[key] = src[key]; + } + } + return obj; +} + +const store = createStore( + combineReducers(reducers), + composeWithDevTools(applyMiddleware(thunk)) +) +export default store
\ No newline at end of file diff --git a/src/colors.ts b/src/colors.ts new file mode 100644 index 0000000..13b5e4f --- /dev/null +++ b/src/colors.ts @@ -0,0 +1,3 @@ +export const COLORS = ['#FF6600', '#0088FF', '#03DC03']; // none, res, enl +export const COLORS_LVL = ['#000', '#FECE5A', '#FFA630', '#FF7315', '#E40000', '#FD2992', '#EB26CD', '#C124E0', '#9627F4']; +export const COLORS_MOD = { VERY_RARE: '#b08cff', RARE: '#73a8ff', COMMON: '#8cffbf' };
\ No newline at end of file diff --git a/src/helper.js b/src/helper.js new file mode 100644 index 0000000..3bdbf7e --- /dev/null +++ b/src/helper.js @@ -0,0 +1,46 @@ +import { Dimensions, Platform, StatusBar } from 'react-native'; + + +export function isIphoneX() { + const dimen = Dimensions.get('window'); + return ( + Platform.OS === 'ios' && + !Platform.isPad && + !Platform.isTVOS && + ((dimen.height === 812 || dimen.width === 812) || (dimen.height === 896 || dimen.width === 896)) + ); +} + +export function ifIphoneX(iphoneXStyle, regularStyle) { + if (isIphoneX()) { + return iphoneXStyle; + } + return regularStyle; +} + +export function getStatusBarHeight(safe) { + return Platform.select({ + ios: ifIphoneX(safe ? 44 : 30, 20), + android: StatusBar.currentHeight + }); +} + +export function getBottomSpace() { + return isIphoneX() ? 34 : 0; +} + +export function timeConverter(UNIX_timestamp) { + var a = new Date(UNIX_timestamp * 1000); + var year = a.getFullYear(); + var month = a.getMonth() + 1; + var date = a.getDate(); + var hour = a.getHours(); + var min = a.getMinutes(); + var sec = a.getSeconds(); + var time = formatInt(hour) + ':' + formatInt(min) + ':' + formatInt(sec) + ' ' + formatInt(date) + '.' + formatInt(month) + '.' + year; + return time; +} + +function formatInt(num) { + return ("00" + num).slice(-2) +}
\ No newline at end of file |