Work on modes
This commit is contained in:
parent
7d3ea8e5dd
commit
cf586e853a
21
server.go
21
server.go
@ -52,38 +52,33 @@ func executeQuery(rw http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
entries := getRecords(queryContext{geo, request.Profile, request.WalkingDist})
|
entries := getRecords(queryContext{geo, request.Profile, request.WalkingDist})
|
||||||
features := fixFeatures(request.Features)
|
features := fixFeatures(request.Features)
|
||||||
|
modes := fixModes(request.Modes)
|
||||||
|
|
||||||
minScore := request.MinScore
|
foundEntries := findRecords(entries, features, modes, request.MinScore)
|
||||||
if request.Bracket != nil {
|
|
||||||
bracket := namedBracket{
|
|
||||||
request.Bracket.Name,
|
|
||||||
request.Bracket.Min,
|
|
||||||
request.Bracket.Max}
|
|
||||||
|
|
||||||
minScore = calibrateMinScore(entries, features, bracket)
|
|
||||||
}
|
|
||||||
|
|
||||||
foundEntries := findRecords(entries, features, minScore)
|
|
||||||
sorter := recordSorter{entries: foundEntries, key: request.SortKey, ascending: request.SortAsc}
|
sorter := recordSorter{entries: foundEntries, key: request.SortKey, ascending: request.SortAsc}
|
||||||
sorter.sort()
|
sorter.sort()
|
||||||
|
|
||||||
response := jsonQueryResponse{
|
response := jsonQueryResponse{
|
||||||
Count: len(foundEntries),
|
Count: len(foundEntries),
|
||||||
Columns: make(map[string]jsonColumn),
|
Columns: make(map[string]jsonColumn),
|
||||||
MinScore: minScore,
|
MinScore: request.MinScore,
|
||||||
Records: make([]jsonRecord, 0)}
|
Records: make([]jsonRecord, 0)}
|
||||||
|
|
||||||
for name, value := range features {
|
for name, value := range features {
|
||||||
|
mode, _ := modes[name]
|
||||||
|
|
||||||
column := jsonColumn{
|
column := jsonColumn{
|
||||||
Bracket: jsonBracket{Max: -1, Min: 1},
|
Bracket: jsonBracket{Max: -1, Min: 1},
|
||||||
|
Mode: mode.String(),
|
||||||
Value: value,
|
Value: value,
|
||||||
Steps: request.Resolution}
|
Steps: request.Resolution}
|
||||||
|
|
||||||
hints := project(
|
hints := project(
|
||||||
entries,
|
entries,
|
||||||
features,
|
features,
|
||||||
|
modes,
|
||||||
name,
|
name,
|
||||||
minScore,
|
request.MinScore,
|
||||||
request.Resolution)
|
request.Resolution)
|
||||||
|
|
||||||
for _, hint := range hints {
|
for _, hint := range hints {
|
||||||
|
@ -88,7 +88,7 @@
|
|||||||
var _height = 500;
|
var _height = 500;
|
||||||
var _padding = 10;
|
var _padding = 10;
|
||||||
var _panelSize = 20;
|
var _panelSize = 20;
|
||||||
var _buttonSize = 20;
|
var _modeSize = 20;
|
||||||
var _tickSize = 5;
|
var _tickSize = 5;
|
||||||
var _width = 120;
|
var _width = 120;
|
||||||
|
|
||||||
@ -111,7 +111,7 @@
|
|||||||
_densitySize,
|
_densitySize,
|
||||||
0,
|
0,
|
||||||
_width - (_bracketSize + _densitySize),
|
_width - (_bracketSize + _densitySize),
|
||||||
_height - (_panelSize + _buttonSize)
|
_height - (_panelSize + _modeSize)
|
||||||
).attr({
|
).attr({
|
||||||
cursor: 'crosshair',
|
cursor: 'crosshair',
|
||||||
stroke: _borderColor,
|
stroke: _borderColor,
|
||||||
@ -123,7 +123,7 @@
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
_densitySize,
|
_densitySize,
|
||||||
_height - (_panelSize + _buttonSize)
|
_height - (_panelSize + _modeSize)
|
||||||
).attr({
|
).attr({
|
||||||
stroke: _borderColor
|
stroke: _borderColor
|
||||||
});
|
});
|
||||||
@ -131,7 +131,7 @@
|
|||||||
// panel
|
// panel
|
||||||
_elements.panel = _canvas.rect(
|
_elements.panel = _canvas.rect(
|
||||||
0,
|
0,
|
||||||
_height - (_panelSize + _buttonSize),
|
_height - (_panelSize + _modeSize),
|
||||||
_width - _bracketSize,
|
_width - _bracketSize,
|
||||||
_panelSize
|
_panelSize
|
||||||
).attr({
|
).attr({
|
||||||
@ -141,7 +141,7 @@
|
|||||||
// label
|
// label
|
||||||
_elements.label = _canvas.text(
|
_elements.label = _canvas.text(
|
||||||
(_width - _bracketSize) / 2,
|
(_width - _bracketSize) / 2,
|
||||||
_height - (_panelSize / 2 + _buttonSize),
|
_height - (_panelSize / 2 + _modeSize),
|
||||||
_name
|
_name
|
||||||
).attr({
|
).attr({
|
||||||
'dominant-baseline': 'middle',
|
'dominant-baseline': 'middle',
|
||||||
@ -153,10 +153,10 @@
|
|||||||
_width - _bracketSize,
|
_width - _bracketSize,
|
||||||
0,
|
0,
|
||||||
_bracketSize,
|
_bracketSize,
|
||||||
_height - (_panelSize + _buttonSize)
|
_height - (_panelSize + _modeSize)
|
||||||
).attr({
|
).attr({
|
||||||
fill: _bracketColorBg
|
fill: _bracketColorBg
|
||||||
}).click(bracketClick);
|
});
|
||||||
|
|
||||||
// indiciator
|
// indiciator
|
||||||
updateIndicator(_data.value);
|
updateIndicator(_data.value);
|
||||||
@ -191,7 +191,7 @@
|
|||||||
_elements.panel,
|
_elements.panel,
|
||||||
_elements.tick,
|
_elements.tick,
|
||||||
_elements.label,
|
_elements.label,
|
||||||
_elements.button
|
_elements.mode
|
||||||
);
|
);
|
||||||
|
|
||||||
_elements.group.transform(
|
_elements.group.transform(
|
||||||
@ -258,7 +258,7 @@
|
|||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
stroke: _bracketColorFg,
|
stroke: _bracketColorFg,
|
||||||
'stroke-dasharray': '5, 1'
|
'stroke-dasharray': '5, 1'
|
||||||
}).click(bracketClick);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_.has(_elements, 'bracketMin')) {
|
if (_.has(_elements, 'bracketMin')) {
|
||||||
@ -297,19 +297,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateMode() {
|
function updateMode() {
|
||||||
if (_.has(_elements, 'button')) {
|
if (_.has(_elements, 'mode')) {
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_elements.button = _canvas.text(
|
_elements.mode = _canvas.text(
|
||||||
(_width - _bracketSize) / 2,
|
(_width - _bracketSize) / 2,
|
||||||
_height - _buttonSize / 2,
|
_height - _modeSize / 2,
|
||||||
''
|
'x'
|
||||||
).attr({
|
).attr({
|
||||||
'dominant-baseline': 'middle',
|
'dominant-baseline': 'middle',
|
||||||
'text-anchor': 'middle'
|
'text-anchor': 'middle'
|
||||||
});
|
}).click(modeClick);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -344,25 +344,12 @@
|
|||||||
return colorStops;
|
return colorStops;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateState(value, bracket) {
|
function updateState(value, mode) {
|
||||||
var updateBracket = bracket !== null;
|
_data.value = _range.clamp(value);
|
||||||
var updateValue = value !== null;
|
_data.mode = mode;
|
||||||
|
|
||||||
if (updateBracket) {
|
|
||||||
_data.bracket.max = _range.clamp(bracket.max);
|
|
||||||
_data.bracket.min = _range.clamp(bracket.min);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateValue) {
|
|
||||||
_data.value = _range.clamp(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_onStateChanged) {
|
if (_onStateChanged) {
|
||||||
_onStateChanged(
|
_onStateChanged(_name, _data.value, _data.mode);
|
||||||
_name,
|
|
||||||
updateValue ? _data.value : null,
|
|
||||||
updateBracket ? _data.bracket : null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
animateIndicator(_valueTrans, _data.value);
|
animateIndicator(_valueTrans, _data.value);
|
||||||
@ -462,19 +449,11 @@
|
|||||||
|
|
||||||
function indicatorClick(event, x, y) {
|
function indicatorClick(event, x, y) {
|
||||||
var rect = _canvas.node.getBoundingClientRect();
|
var rect = _canvas.node.getBoundingClientRect();
|
||||||
|
updateState(indicatorToValue(y - rect.top), _data.mode);
|
||||||
updateState(indicatorToValue(y - rect.top), null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bracketClick(event, x, y) {
|
function modeClick(event, x, y) {
|
||||||
var mid = (_data.bracket.min + _data.bracket.max) / 2;
|
alert('mode clicked');
|
||||||
var rect = _canvas.node.getBoundingClientRect();
|
|
||||||
|
|
||||||
var dist = Math.abs(mid - bracketToValue(y - rect.top));
|
|
||||||
dist = Math.min(dist, Math.abs(_range.max - mid));
|
|
||||||
dist = Math.min(dist, Math.abs(_range.min - mid));
|
|
||||||
|
|
||||||
updateState(_data.value, {max: mid + dist, min: mid - dist});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update = function(data, scale) {
|
this.update = function(data, scale) {
|
||||||
@ -492,6 +471,10 @@
|
|||||||
_data.bracket = data.bracket;
|
_data.bracket = data.bracket;
|
||||||
animateBracket(_bracketTrans, _data.bracket);
|
animateBracket(_bracketTrans, _data.bracket);
|
||||||
}
|
}
|
||||||
|
if (_.has(data, 'mode')) {
|
||||||
|
_data.mode = data.mode;
|
||||||
|
updateMode();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
createShapes();
|
createShapes();
|
||||||
|
@ -25,18 +25,9 @@
|
|||||||
|
|
||||||
var _ctx = {};
|
var _ctx = {};
|
||||||
|
|
||||||
function onStateChanged(name, value, bracket) {
|
function onStateChanged(name, value, mode) {
|
||||||
_ctx.query.features[name] = value;
|
_ctx.query.features[name] = value;
|
||||||
if (bracket === null) {
|
_ctx.query.modes[name] = mode;
|
||||||
_ctx.query.bracket = null;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
_ctx.query.bracket = {
|
|
||||||
name: name,
|
|
||||||
min: bracket.min,
|
|
||||||
max: bracket.max
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
$.post('/query', JSON.stringify(_ctx.query), function(results) {
|
$.post('/query', JSON.stringify(_ctx.query), function(results) {
|
||||||
saveSnapshot(results);
|
saveSnapshot(results);
|
||||||
@ -87,7 +78,7 @@
|
|||||||
function onSearch() {
|
function onSearch() {
|
||||||
_ctx.query = {
|
_ctx.query = {
|
||||||
features: _ctx.query.features || {},
|
features: _ctx.query.features || {},
|
||||||
bracket: null,
|
modes: _ctx.query.modes || {},
|
||||||
sortKey: _ctx.sortKey,
|
sortKey: _ctx.sortKey,
|
||||||
sortAsc: _ctx.sortAsc,
|
sortAsc: _ctx.sortAsc,
|
||||||
profile: getProfile(),
|
profile: getProfile(),
|
||||||
@ -132,7 +123,8 @@
|
|||||||
columns[feature] = {
|
columns[feature] = {
|
||||||
value: column.value,
|
value: column.value,
|
||||||
hints: column.hints,
|
hints: column.hints,
|
||||||
bracket: column.bracket
|
bracket: column.bracket,
|
||||||
|
mode: column.mode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
45
types.go
45
types.go
@ -24,7 +24,16 @@ package main
|
|||||||
|
|
||||||
import "sort"
|
import "sort"
|
||||||
|
|
||||||
|
type modeType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeTypeNone modeType = iota
|
||||||
|
ModeTypeProduct
|
||||||
|
ModeTypeDist
|
||||||
|
)
|
||||||
|
|
||||||
type featureMap map[string]float64
|
type featureMap map[string]float64
|
||||||
|
type modeMap map[string]modeType
|
||||||
|
|
||||||
type jsonAccessRequest struct {
|
type jsonAccessRequest struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
@ -37,11 +46,11 @@ type jsonGeoData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type jsonQueryRequest struct {
|
type jsonQueryRequest struct {
|
||||||
Bracket *jsonNamedBracket `json:"bracket"`
|
|
||||||
Features featureMap `json:"features"`
|
Features featureMap `json:"features"`
|
||||||
Geo *jsonGeoData `json:"geo"`
|
Geo *jsonGeoData `json:"geo"`
|
||||||
MaxResults int `json:"maxResults"`
|
MaxResults int `json:"maxResults"`
|
||||||
MinScore float64 `json:"minScore"`
|
MinScore float64 `json:"minScore"`
|
||||||
|
Modes map[string]string `json:"modeMap"`
|
||||||
Profile featureMap `json:"profile"`
|
Profile featureMap `json:"profile"`
|
||||||
Resolution int `json:"resolution"`
|
Resolution int `json:"resolution"`
|
||||||
SortAsc bool `json:"sortAsc"`
|
SortAsc bool `json:"sortAsc"`
|
||||||
@ -52,6 +61,7 @@ type jsonQueryRequest struct {
|
|||||||
type jsonColumn struct {
|
type jsonColumn struct {
|
||||||
Bracket jsonBracket `json:"bracket"`
|
Bracket jsonBracket `json:"bracket"`
|
||||||
Hints []jsonProjection `json:"hints"`
|
Hints []jsonProjection `json:"hints"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
Steps int `json:"steps"`
|
Steps int `json:"steps"`
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
}
|
}
|
||||||
@ -79,11 +89,6 @@ type jsonBracket struct {
|
|||||||
Max float64 `json:"max"`
|
Max float64 `json:"max"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsonNamedBracket struct {
|
|
||||||
jsonBracket
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsonQueryResponse struct {
|
type jsonQueryResponse struct {
|
||||||
Columns map[string]jsonColumn `json:"columns"`
|
Columns map[string]jsonColumn `json:"columns"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
@ -131,12 +136,6 @@ type geoData struct {
|
|||||||
longitude float64
|
longitude float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type namedBracket struct {
|
|
||||||
name string
|
|
||||||
min float64
|
|
||||||
max float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type record struct {
|
type record struct {
|
||||||
accessCount int
|
accessCount int
|
||||||
closestStn string
|
closestStn string
|
||||||
@ -196,3 +195,25 @@ func (s recordSorter) Less(i, j int) bool {
|
|||||||
func (s recordSorter) Swap(i, j int) {
|
func (s recordSorter) Swap(i, j int) {
|
||||||
s.entries[i], s.entries[j] = s.entries[j], s.entries[i]
|
s.entries[i], s.entries[j] = s.entries[j], s.entries[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m modeType) String() string {
|
||||||
|
switch m {
|
||||||
|
case ModeTypeProduct:
|
||||||
|
return "prod"
|
||||||
|
case ModeTypeDist:
|
||||||
|
return "dist"
|
||||||
|
default:
|
||||||
|
return "invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strToModeType(mode string) modeType {
|
||||||
|
switch mode {
|
||||||
|
case "prod":
|
||||||
|
return ModeTypeProduct
|
||||||
|
case "dist":
|
||||||
|
return ModeTypeDist
|
||||||
|
default:
|
||||||
|
return ModeTypeNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
80
util.go
80
util.go
@ -47,6 +47,16 @@ func fixFeatures(features featureMap) featureMap {
|
|||||||
return fixedFeatures
|
return fixedFeatures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fixModes(modes map[string]string) modeMap {
|
||||||
|
result := make(modeMap)
|
||||||
|
|
||||||
|
for name, value := range modes {
|
||||||
|
result[name] = strToModeType(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func innerProduct(features1 featureMap, features2 featureMap) float64 {
|
func innerProduct(features1 featureMap, features2 featureMap) float64 {
|
||||||
var result float64
|
var result float64
|
||||||
for key, value1 := range features1 {
|
for key, value1 := range features1 {
|
||||||
@ -57,19 +67,35 @@ func innerProduct(features1 featureMap, features2 featureMap) float64 {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func walkMatches(entries records, features featureMap, minScore float64, callback func(record, float64)) {
|
func compare(features1 featureMap, features2 featureMap, modes modeMap) float64 {
|
||||||
|
var result float64
|
||||||
|
for key, value1 := range features1 {
|
||||||
|
value2, _ := features2[key]
|
||||||
|
|
||||||
|
switch mode, _ := modes[key]; mode {
|
||||||
|
case ModeTypeDist:
|
||||||
|
result += 1 - math.Abs(value1-value2)/2
|
||||||
|
default:
|
||||||
|
result += value1 * value2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkMatches(entries records, features featureMap, modes modeMap, minScore float64, callback func(record, float64)) {
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if score := innerProduct(features, entry.features); score >= minScore {
|
if score := compare(features, entry.features, modes); score >= minScore {
|
||||||
callback(entry, score)
|
callback(entry, score)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func statRecords(entries records, features featureMap, minScore float64) (float64, int) {
|
func statRecords(entries records, features featureMap, modes modeMap, minScore float64) (float64, int) {
|
||||||
var compatibility float64
|
var compatibility float64
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
walkMatches(entries, features, minScore, func(entry record, score float64) {
|
walkMatches(entries, features, modes, minScore, func(entry record, score float64) {
|
||||||
compatibility += entry.compatibility
|
compatibility += entry.compatibility
|
||||||
count++
|
count++
|
||||||
})
|
})
|
||||||
@ -89,10 +115,10 @@ func stepRange(min, max float64, steps int, callback func(float64)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func findRecords(entries records, features featureMap, minScore float64) records {
|
func findRecords(entries records, features featureMap, modes modeMap, minScore float64) records {
|
||||||
var foundEntries records
|
var foundEntries records
|
||||||
|
|
||||||
walkMatches(entries, features, minScore, func(entry record, score float64) {
|
walkMatches(entries, features, modes, minScore, func(entry record, score float64) {
|
||||||
entry.score = score
|
entry.score = score
|
||||||
foundEntries = append(foundEntries, entry)
|
foundEntries = append(foundEntries, entry)
|
||||||
})
|
})
|
||||||
@ -100,45 +126,7 @@ func findRecords(entries records, features featureMap, minScore float64) records
|
|||||||
return foundEntries
|
return foundEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
func calibrateMinScore(entries records, features featureMap, bracket namedBracket) float64 {
|
func project(entries records, features featureMap, modes modeMap, featureName string, minScore float64, steps int) []queryProjection {
|
||||||
bestScoreRank := -math.MaxFloat64
|
|
||||||
var bestMinScore float64
|
|
||||||
|
|
||||||
for minScore := float64(-len(features)); minScore <= float64(len(features)); minScore += 0.1 {
|
|
||||||
var scoreRank float64
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
value, ok := entry.features[bracket.name]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
score := innerProduct(features, entry.features)
|
|
||||||
if score < minScore {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if score > minScore {
|
|
||||||
if value >= bracket.min && value <= bracket.max {
|
|
||||||
dist := math.Abs(value - features[bracket.name])
|
|
||||||
scoreRank += 1 / (dist * dist)
|
|
||||||
} else {
|
|
||||||
dist := math.Min(math.Abs(value-bracket.min), math.Abs(value-bracket.max))
|
|
||||||
scoreRank -= 1 / (dist * dist)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if scoreRank > bestScoreRank {
|
|
||||||
bestScoreRank = scoreRank
|
|
||||||
bestMinScore = minScore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMinScore
|
|
||||||
}
|
|
||||||
|
|
||||||
func project(entries records, features featureMap, featureName string, minScore float64, steps int) []queryProjection {
|
|
||||||
sampleFeatures := make(featureMap)
|
sampleFeatures := make(featureMap)
|
||||||
for key, value := range features {
|
for key, value := range features {
|
||||||
sampleFeatures[key] = value
|
sampleFeatures[key] = value
|
||||||
@ -147,7 +135,7 @@ func project(entries records, features featureMap, featureName string, minScore
|
|||||||
var projection []queryProjection
|
var projection []queryProjection
|
||||||
stepRange(-1.0, 1.0, steps, func(sample float64) {
|
stepRange(-1.0, 1.0, steps, func(sample float64) {
|
||||||
sample, sampleFeatures[featureName] = sampleFeatures[featureName], sample
|
sample, sampleFeatures[featureName] = sampleFeatures[featureName], sample
|
||||||
compatibility, count := statRecords(entries, sampleFeatures, minScore)
|
compatibility, count := statRecords(entries, sampleFeatures, modes, minScore)
|
||||||
sample, sampleFeatures[featureName] = sampleFeatures[featureName], sample
|
sample, sampleFeatures[featureName] = sampleFeatures[featureName], sample
|
||||||
|
|
||||||
projection = append(projection, queryProjection{compatibility, count, sample})
|
projection = append(projection, queryProjection{compatibility, count, sample})
|
||||||
|
Loading…
Reference in New Issue
Block a user